feat: Implement floating card preview on hover with boundary detection for list and grid views.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { DraftCard, Pack } from '../services/PackGeneratorService';
|
||||
import { Copy } from 'lucide-react';
|
||||
import { StackView } from './StackView';
|
||||
@@ -8,6 +8,84 @@ interface PackCardProps {
|
||||
viewMode: 'list' | 'grid' | 'stack';
|
||||
}
|
||||
|
||||
// --- Floating Preview Component ---
|
||||
const FloatingPreview: React.FC<{ card: DraftCard; x: number; y: number }> = ({ card, x, y }) => {
|
||||
const isFoil = card.finish === 'foil';
|
||||
const imgRef = useRef<HTMLImageElement>(null);
|
||||
|
||||
// Basic boundary detection to prevent going off-screen
|
||||
// We check window dimensions. This might need customization based on the actual viewport,
|
||||
// but window is a good safe default.
|
||||
const [adjustedPos, setAdjustedPos] = useState({ top: y, left: x });
|
||||
|
||||
useEffect(() => {
|
||||
// Offset from cursor
|
||||
const OFFSET = 20;
|
||||
const CARD_WIDTH = 300; // Approx width of preview
|
||||
const CARD_HEIGHT = 420; // Approx height of preview
|
||||
|
||||
let newX = x + OFFSET;
|
||||
let newY = y + OFFSET;
|
||||
|
||||
// Flip horizontally if too close to right edge
|
||||
if (newX + CARD_WIDTH > window.innerWidth) {
|
||||
newX = x - CARD_WIDTH - OFFSET;
|
||||
}
|
||||
|
||||
// Flip vertically if too close to bottom edge
|
||||
if (newY + CARD_HEIGHT > window.innerHeight) {
|
||||
newY = y - CARD_HEIGHT - OFFSET;
|
||||
}
|
||||
|
||||
setAdjustedPos({ top: newY, left: newX });
|
||||
|
||||
}, [x, y]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed z-[9999] pointer-events-none transition-opacity duration-75"
|
||||
style={{
|
||||
top: adjustedPos.top,
|
||||
left: adjustedPos.left
|
||||
}}
|
||||
>
|
||||
<div className="relative w-[300px] rounded-xl overflow-hidden shadow-2xl border-4 border-slate-900 bg-black">
|
||||
<img ref={imgRef} src={card.image} alt={card.name} className="w-full h-auto" />
|
||||
{isFoil && <div className="absolute inset-0 bg-gradient-to-br from-purple-500/20 to-blue-500/20 mix-blend-overlay animate-pulse"></div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// --- Hover Wrapper to handle mouse events ---
|
||||
const CardHoverWrapper: React.FC<{ card: DraftCard; children: React.ReactNode; className?: string }> = ({ card, children, className }) => {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Only show preview if there is an image
|
||||
const hasImage = !!card.image;
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!hasImage) return;
|
||||
setMousePos({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
{children}
|
||||
{isHovering && hasImage && (
|
||||
<FloatingPreview card={card} x={mousePos.x} y={mousePos.y} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
const isFoil = (card: DraftCard) => card.finish === 'foil';
|
||||
|
||||
@@ -22,8 +100,8 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<li className="relative group">
|
||||
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer">
|
||||
<CardHoverWrapper card={card} className="relative group">
|
||||
<div className="flex items-center justify-between py-1 px-2 rounded hover:bg-slate-700/50 cursor-pointer transition-colors">
|
||||
<span className={`font-medium flex items-center gap-2 ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
|
||||
{card.name}
|
||||
{isFoil(card) && (
|
||||
@@ -34,15 +112,7 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||
</span>
|
||||
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
|
||||
</div>
|
||||
{card.image && (
|
||||
<div className="hidden group-hover:block absolute left-0 top-full z-50 mt-1 pointer-events-none">
|
||||
<div className="bg-black p-1 rounded-lg border border-slate-500 shadow-2xl w-48 relative overflow-hidden">
|
||||
{isFoil(card) && <div className="absolute inset-0 bg-gradient-to-br from-white/20 via-transparent to-white/10 opacity-50 z-10 pointer-events-none mix-blend-overlay animate-pulse" />}
|
||||
<img src={card.image} alt={card.name} className="w-full rounded relative z-0" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
</CardHoverWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -51,12 +121,12 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
const rares = pack.cards.filter(c => c.rarity === 'rare');
|
||||
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
|
||||
const commons = pack.cards.filter(c => c.rarity === 'common');
|
||||
|
||||
const isFoil = (card: DraftCard) => card.finish === 'foil';
|
||||
|
||||
const copyPackToClipboard = () => {
|
||||
const text = pack.cards.map(c => c.name).join('\n');
|
||||
navigator.clipboard.writeText(text);
|
||||
// Toast notification could go here
|
||||
alert(`Pack list ${pack.id} copied!`);
|
||||
};
|
||||
|
||||
@@ -104,23 +174,29 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
||||
{viewMode === 'grid' && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||
{pack.cards.map((card) => (
|
||||
<div key={card.id} className={`relative aspect-[2.5/3.5] bg-slate-900 rounded-lg overflow-hidden group hover:scale-105 transition-transform duration-200 shadow-xl border ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil(card) && <div className="absolute inset-0 z-20 bg-gradient-to-tr from-purple-500/10 via-transparent to-pink-500/10 mix-blend-color-dodge pointer-events-none" />}
|
||||
{isFoil(card) && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
|
||||
<CardHoverWrapper key={card.id} card={card}>
|
||||
<div className="relative group bg-slate-900 rounded-lg">
|
||||
{/* Visual Card */}
|
||||
<div className={`relative aspect-[2.5/3.5] overflow-hidden rounded-lg shadow-xl border transition-all duration-200 group-hover:ring-2 group-hover:ring-purple-400 group-hover:shadow-purple-500/30 cursor-pointer ${isFoil(card) ? 'border-purple-400 shadow-purple-500/20' : 'border-slate-800'}`}>
|
||||
{isFoil(card) && <div className="absolute inset-0 z-20 bg-gradient-to-tr from-purple-500/10 via-transparent to-pink-500/10 mix-blend-color-dodge pointer-events-none" />}
|
||||
{isFoil(card) && <div className="absolute top-1 right-1 z-30 text-[10px] font-bold text-white bg-purple-600/80 px-1 rounded backdrop-blur-sm">FOIL</div>}
|
||||
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
|
||||
{card.name}
|
||||
{card.image ? (
|
||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center text-xs text-center p-1 text-slate-500 font-bold border-2 border-slate-700 m-1 rounded">
|
||||
{card.name}
|
||||
</div>
|
||||
)}
|
||||
{/* Rarity Stripe */}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
|
||||
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
|
||||
'bg-black'
|
||||
}`} />
|
||||
</div>
|
||||
)}
|
||||
<div className={`absolute bottom-0 left-0 right-0 h-1.5 ${card.rarity === 'mythic' ? 'bg-gradient-to-r from-orange-500 to-red-600' :
|
||||
card.rarity === 'rare' ? 'bg-gradient-to-r from-yellow-400 to-yellow-600' :
|
||||
card.rarity === 'uncommon' ? 'bg-gradient-to-r from-gray-300 to-gray-500' :
|
||||
'bg-black'
|
||||
}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardHoverWrapper>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users } from 'lucide-react';
|
||||
import { Layers, RotateCcw, Box, Check, Loader2, Upload, LayoutGrid, List, Sliders, Settings, Users, Download, Copy, FileDown } from 'lucide-react';
|
||||
import { CardParserService } from '../../services/CardParserService';
|
||||
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||
@@ -23,6 +23,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [progress, setProgress] = useState('');
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
|
||||
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(null);
|
||||
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
|
||||
@@ -73,36 +74,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
event.target.value = '';
|
||||
};
|
||||
|
||||
const loadDemoData = () => {
|
||||
const demo = `20 Shock
|
||||
20 Llanowar Elves
|
||||
20 Giant Growth
|
||||
20 Counterspell
|
||||
20 Dark Ritual
|
||||
20 Lightning Bolt
|
||||
20 Opt
|
||||
20 Consider
|
||||
20 Ponder
|
||||
20 Preordain
|
||||
20 Brainstorm
|
||||
20 Duress
|
||||
20 Faithless Looting
|
||||
20 Thrill of Possibility
|
||||
20 Terror
|
||||
10 Serra Angel
|
||||
10 Vampire Nighthawk
|
||||
10 Eternal Witness
|
||||
10 Mulldrifter
|
||||
10 Flametongue Kavu
|
||||
5 Wrath of God
|
||||
5 Birds of Paradise
|
||||
2 Jace, the Mind Sculptor
|
||||
1 Sheoldred, the Apocalypse
|
||||
20 Island
|
||||
1 Sol Ring
|
||||
1 Command Tower`;
|
||||
setInputText(demo);
|
||||
};
|
||||
|
||||
|
||||
const fetchAndParse = async () => {
|
||||
setLoading(true);
|
||||
@@ -182,6 +154,64 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (packs.length === 0) return;
|
||||
const csvContent = generatorService.generateCsv(packs);
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `generated_packs_${new Date().toISOString().slice(0, 10)}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleDownloadTemplate = () => {
|
||||
const template = `Quantity,Name,Finish,Edition Name,Scryfall ID
|
||||
5,Agate Assault,Normal,Bloomburrow,7dd9946b-515e-4e0d-9da2-711e126e9fa6
|
||||
1,Agate-Blade Assassin,Normal,Bloomburrow,39ebb84a-1c52-4b07-9bd0-b360523b3a5b
|
||||
4,Agate-Blade Assassin,Normal,Bloomburrow,39ebb84a-1c52-4b07-9bd0-b360523b3a5b
|
||||
4,Alania's Pathmaker,Normal,Bloomburrow,d3871fe6-e26e-4ab4-bd81-7e3c7b8135c1
|
||||
1,Artist's Talent,Normal,Bloomburrow,8b9e51d9-189b-4dd6-87cb-628ea6373e81
|
||||
1,Azure Beastbinder,Normal,Bloomburrow,211af1bf-910b-41a5-b928-f378188d1871
|
||||
3,Bakersbane Duo,Normal,Bloomburrow,5309354f-1ff4-4fa9-9141-01ea2f7588ab
|
||||
2,Bandit's Talent,Normal,Bloomburrow,485dc8d8-9e44-4a0f-9ff6-fa448e232290
|
||||
3,Banishing Light,Normal,Bloomburrow,25a06f82-ebdb-4dd6-bfe8-958018ce557c
|
||||
4,Barkform Harvester,Normal,Bloomburrow,f77049a6-0f22-415b-bc89-20bcb32accf6
|
||||
1,Bark-Knuckle Boxer,Normal,Bloomburrow,582637a9-6aa0-4824-bed7-d5fc91bda35e
|
||||
1,"Baylen, the Haymaker",Normal,Bloomburrow,00e93be2-e06b-4774-8ba5-ccf82a6da1d8
|
||||
3,Bellowing Crier,Normal,Bloomburrow,ca2215dd-6300-49cf-b9b2-3a840b786c31
|
||||
1,Blacksmith's Talent,Normal,Bloomburrow,4bb318fa-481d-40a7-978e-f01b49101ae0
|
||||
1,Blooming Blast,Normal,Bloomburrow,0cd92a83-cec3-4085-a929-3f204e3e0140
|
||||
4,Bonebind Orator,Normal,Bloomburrow,faf226fa-ca09-4468-8804-87b2a7de2c66
|
||||
2,Bonecache Overseer,Normal,Bloomburrow,82defb87-237f-4b77-9673-5bf00607148f
|
||||
1,Brambleguard Captain,Foil,Bloomburrow,e200b8bf-f2f3-4157-8e04-02baf07a963e`;
|
||||
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.setAttribute('href', url);
|
||||
link.setAttribute('download', `import_template.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
const handleCopyCsv = async () => {
|
||||
if (packs.length === 0) return;
|
||||
const csvContent = generatorService.generateCsv(packs);
|
||||
try {
|
||||
await navigator.clipboard.writeText(csvContent);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy: ', err);
|
||||
alert('Failed to copy CSV to clipboard');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFilter = (key: keyof typeof filters) => {
|
||||
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
};
|
||||
@@ -225,7 +255,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
<Upload className="w-3 h-3" /> Upload
|
||||
</button>
|
||||
<input type="file" ref={fileInputRef} className="hidden" accept=".csv,.txt" onChange={handleFileUpload} />
|
||||
<button onClick={loadDemoData} className="text-xs text-purple-400 hover:text-purple-300 hover:underline">Demo</button>
|
||||
<button onClick={handleDownloadTemplate} className="text-xs text-emerald-400 hover:text-emerald-300 flex items-center gap-1 hover:underline">
|
||||
<FileDown className="w-3 h-3" /> Template
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -392,12 +424,29 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
||||
<div className="flex gap-2">
|
||||
{/* Play Button */}
|
||||
{packs.length > 0 && (
|
||||
<button
|
||||
onClick={onGoToLobby}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
|
||||
</button>
|
||||
<>
|
||||
<button
|
||||
onClick={onGoToLobby}
|
||||
className="px-4 py-2 bg-gradient-to-r from-purple-600 to-pink-600 hover:from-purple-500 hover:to-pink-500 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
>
|
||||
<Users className="w-4 h-4" /> <span className="hidden sm:inline">Play Online</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
title="Export as CSV"
|
||||
>
|
||||
<Download className="w-4 h-4" /> <span className="hidden sm:inline">Export</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopyCsv}
|
||||
className="px-4 py-2 bg-slate-700 hover:bg-slate-600 text-white font-bold rounded-lg shadow-lg flex items-center gap-2 animate-in fade-in zoom-in"
|
||||
title="Copy CSV to Clipboard"
|
||||
>
|
||||
{copySuccess ? <Check className="w-4 h-4 text-emerald-400" /> : <Copy className="w-4 h-4" />}
|
||||
<span className="hidden sm:inline">{copySuccess ? 'Copied!' : 'Copy'}</span>
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
|
||||
|
||||
@@ -16,60 +16,98 @@ export class CardParserService {
|
||||
if (line.toLowerCase().startsWith('quantity') && line.toLowerCase().includes('name')) return;
|
||||
|
||||
const idMatch = line.match(uuidRegex);
|
||||
const cleanLineForQty = line.replace(/['"]/g, '');
|
||||
const quantityMatch = cleanLineForQty.match(/^(\d+)[xX\s,;]/);
|
||||
const quantity = quantityMatch ? parseInt(quantityMatch[1], 10) : 1;
|
||||
|
||||
// Detect Finish from CSV (Comma Separated)
|
||||
let finish: 'foil' | 'normal' | undefined = undefined;
|
||||
const parts = line.split(',');
|
||||
if (parts.length >= 3) {
|
||||
// Assuming format: Quantity,Name,Finish,...
|
||||
// If the line started with a number, parts[0] is quantity. parts[1] is name. parts[2] is Finish.
|
||||
// We should be careful about commas in names, but the user example shows a clean structure.
|
||||
// If the name is quoted, split(',') might be naive, but valid for the provided example.
|
||||
// Let's assume the user provided format: Quantity,Name,Finish,Edition Name,Scryfall ID
|
||||
|
||||
const possibleFinish = parts[2].trim().toLowerCase();
|
||||
if (possibleFinish === 'foil' || possibleFinish === 'etched') finish = 'foil';
|
||||
else if (possibleFinish === 'normal') finish = 'normal';
|
||||
}
|
||||
|
||||
let identifier: { type: 'id' | 'name', value: string } | null = null;
|
||||
|
||||
if (idMatch) {
|
||||
identifier = { type: 'id', value: idMatch[0] };
|
||||
} else {
|
||||
const cleanLine = line.replace(/['"]/g, '');
|
||||
// Remove leading quantity
|
||||
let name = cleanLine.replace(/^(\d+)[xX\s,;]+/, '').trim();
|
||||
// Extract quantity if present before ID, otherwise default to 1
|
||||
// Simple check: Look for "Nx ID" or "N, ID" pattern?
|
||||
// The previous/standard logic usually treats ID lines as 1x unless specified.
|
||||
// Let's try to find a quantity at the start if it exists differently from UUID.
|
||||
// But usually UUID lines are direct from export.
|
||||
|
||||
// Remove set codes in parentheses/brackets e.g. (M20), [STA]
|
||||
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
|
||||
// But our CSV template puts ID at the end.
|
||||
// If UUID is present anywhere in the line, we might trust it over the name.
|
||||
// Let's stick to the previous logic: if UUID is found, use it.
|
||||
// BUT, we should try to parse the whole CSV line if possible to get Finish and Quantity.
|
||||
|
||||
// Remove trailing collector numbers (digits at the very end)
|
||||
name = name.replace(/\s+\d+$/, '');
|
||||
// Let's parse with CSV logic first.
|
||||
const parts = this.parseCsvLine(line);
|
||||
if (parts.length >= 2) {
|
||||
const qty = parseInt(parts[0]);
|
||||
// If valid CSV structure
|
||||
if (!isNaN(qty)) {
|
||||
const name = parts[1]; // We can keep name for reference, but we use ID if present
|
||||
const finishRaw = parts[2]?.toLowerCase();
|
||||
const finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
|
||||
|
||||
// Remove trailing punctuation
|
||||
name = name.replace(/^[,;]+|[,;]+$/g, '').trim();
|
||||
// If the last part has UUID, use it.
|
||||
const uuidPart = parts.find(p => uuidRegex.test(p));
|
||||
if (uuidPart) {
|
||||
const uuid = uuidPart.match(uuidRegex)![0];
|
||||
rawCardList.push({ type: 'id', value: uuid, quantity: qty, finish });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If CSV like "Name, SetCode", take first part
|
||||
if (name.includes(',')) name = name.split(',')[0].trim();
|
||||
|
||||
if (name && name.length > 1) identifier = { type: 'name', value: name };
|
||||
// Fallback ID logic
|
||||
rawCardList.push({ type: 'id', value: idMatch[0], quantity: 1 }); // Default simple UUID match
|
||||
return;
|
||||
}
|
||||
|
||||
if (identifier) {
|
||||
rawCardList.push({
|
||||
type: identifier.type,
|
||||
value: identifier.value,
|
||||
quantity: quantity,
|
||||
finish: finish
|
||||
});
|
||||
// Not an ID match, try parsing as name
|
||||
const parts = this.parseCsvLine(line);
|
||||
|
||||
if (parts.length >= 2 && !isNaN(parseInt(parts[0]))) {
|
||||
// It looks like result of our CSV: Quantity, Name, Finish, ...
|
||||
const quantity = parseInt(parts[0]);
|
||||
const name = parts[1];
|
||||
const finishRaw = parts[2]?.toLowerCase();
|
||||
const finish = (finishRaw === 'foil' || finishRaw === 'etched') ? 'foil' : (finishRaw === 'normal' ? 'normal' : undefined);
|
||||
|
||||
if (name && name.length > 0) {
|
||||
rawCardList.push({ type: 'name', value: name, quantity, finish });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to simple Arena/MTGO text format: "4 Lightning Bolt"
|
||||
const cleanLine = line.replace(/['"]/g, '');
|
||||
const simpleMatch = cleanLine.match(/^(\d+)[xX\s]+(.+)$/);
|
||||
if (simpleMatch) {
|
||||
let name = simpleMatch[2].trim();
|
||||
// cleanup
|
||||
name = name.replace(/\s*[\(\[].*?[\)\]]/g, ''); // remove set codes
|
||||
name = name.replace(/\s+\d+$/, ''); // remove collector number
|
||||
|
||||
rawCardList.push({ type: 'name', value: name, quantity: parseInt(simpleMatch[1]) });
|
||||
} else {
|
||||
// Maybe just "Lightning Bolt" (1x)
|
||||
let name = cleanLine.trim();
|
||||
if (name) {
|
||||
rawCardList.push({ type: 'name', value: name, quantity: 1 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (rawCardList.length === 0) throw new Error("No valid cards found.");
|
||||
return rawCardList;
|
||||
}
|
||||
|
||||
private parseCsvLine(line: string): string[] {
|
||||
const parts: string[] = [];
|
||||
let current = '';
|
||||
let inQuote = false;
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
if (char === '"') {
|
||||
inQuote = !inQuote;
|
||||
} else if (char === ',' && !inQuote) {
|
||||
parts.push(current.trim().replace(/^"|"$/g, '')); // Parsing finished, strip outer quotes if just accumulated
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
parts.push(current.trim().replace(/^"|"$/g, ''));
|
||||
return parts;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -311,4 +311,17 @@ export class PackGeneratorService {
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
generateCsv(packs: Pack[]): string {
|
||||
const header = "Pack ID,Name,Set Code,Rarity,Finish,Scryfall ID\n";
|
||||
const rows = packs.flatMap(pack =>
|
||||
pack.cards.map(card => {
|
||||
const finish = card.finish || 'normal';
|
||||
// Escape quotes in name if necessary
|
||||
const safeName = card.name.includes(',') ? `"${card.name}"` : card.name;
|
||||
return `${pack.id},${safeName},${card.setCode},${card.rarity},${finish},${card.scryfallId}`;
|
||||
})
|
||||
);
|
||||
return header + rows.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user