From 618a2dd09d7ff116731d834b805558bb9aef437f Mon Sep 17 00:00:00 2001 From: dnviti Date: Tue, 16 Dec 2025 13:40:45 +0100 Subject: [PATCH] feat: Implement floating card preview on hover with boundary detection for list and grid views. --- ...25-12-16-131921_export_generated_packs_csv | 6 + src/client/src/components/PackCard.tsx | 132 ++++++++++++++---- src/client/src/modules/cube/CubeManager.tsx | 125 ++++++++++++----- src/client/src/services/CardParserService.ts | 124 ++++++++++------ .../src/services/PackGeneratorService.ts | 13 ++ 5 files changed, 291 insertions(+), 109 deletions(-) create mode 100644 docs/development/devlog/2025-12-16-131921_export_generated_packs_csv diff --git a/docs/development/devlog/2025-12-16-131921_export_generated_packs_csv b/docs/development/devlog/2025-12-16-131921_export_generated_packs_csv new file mode 100644 index 0000000..27ba954 --- /dev/null +++ b/docs/development/devlog/2025-12-16-131921_export_generated_packs_csv @@ -0,0 +1,6 @@ +Implemented CSV export for generated packs and cards. +Implemented CSV copy to clipboard functionality. +Implemented CSV import template download. +Removed demo button and functionality from CubeManager. +Updated CSV import template content. +Refactored parsing logic to support complex CSV imports. diff --git a/src/client/src/components/PackCard.tsx b/src/client/src/components/PackCard.tsx index b2196ac..e25bc4d 100644 --- a/src/client/src/components/PackCard.tsx +++ b/src/client/src/components/PackCard.tsx @@ -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(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 ( +
+
+ {card.name} + {isFoil &&
} +
+
+ ); +}; + +// --- 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 ( +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onMouseMove={handleMouseMove} + > + {children} + {isHovering && hasImage && ( + + )} +
+ ); +}; + + 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 ( -
  • -
    + +
    {card.name} {isFoil(card) && ( @@ -34,15 +112,7 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
    - {card.image && ( -
    -
    - {isFoil(card) &&
    } - {card.name} -
    -
    - )} -
  • + ); }; @@ -51,12 +121,12 @@ export const PackCard: React.FC = ({ 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 = ({ pack, viewMode }) => { {viewMode === 'grid' && (
    {pack.cards.map((card) => ( -
    - {isFoil(card) &&
    } - {isFoil(card) &&
    FOIL
    } + +
    + {/* Visual Card */} +
    + {isFoil(card) &&
    } + {isFoil(card) &&
    FOIL
    } - {card.image ? ( - {card.name} - ) : ( -
    - {card.name} + {card.image ? ( + {card.name} + ) : ( +
    + {card.name} +
    + )} + {/* Rarity Stripe */} +
    - )} -
    -
    +
    + ))}
    )} diff --git a/src/client/src/modules/cube/CubeManager.tsx b/src/client/src/modules/cube/CubeManager.tsx index a6cde6b..0cc247a 100644 --- a/src/client/src/modules/cube/CubeManager.tsx +++ b/src/client/src/modules/cube/CubeManager.tsx @@ -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 = ({ 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(null); const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null); @@ -73,36 +74,7 @@ export const CubeManager: React.FC = ({ 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 = ({ 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 = ({ packs, setPacks, onGoT Upload - +
    @@ -392,12 +424,29 @@ export const CubeManager: React.FC = ({ packs, setPacks, onGoT
    {/* Play Button */} {packs.length > 0 && ( - + <> + + + + )}
    diff --git a/src/client/src/services/CardParserService.ts b/src/client/src/services/CardParserService.ts index 4f73a1f..fae4786 100644 --- a/src/client/src/services/CardParserService.ts +++ b/src/client/src/services/CardParserService.ts @@ -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; + } } diff --git a/src/client/src/services/PackGeneratorService.ts b/src/client/src/services/PackGeneratorService.ts index d2a57cd..27ac5fd 100644 --- a/src/client/src/services/PackGeneratorService.ts +++ b/src/client/src/services/PackGeneratorService.ts @@ -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'); + } }