feat: Implement floating card preview on hover with boundary detection for list and grid views.

This commit is contained in:
2025-12-16 13:40:45 +01:00
parent 8433d02e5b
commit 618a2dd09d
5 changed files with 291 additions and 109 deletions

View File

@@ -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.

View File

@@ -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,7 +174,10 @@ 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'}`}>
<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>}
@@ -115,12 +188,15 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
{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>
</CardHoverWrapper>
))}
</div>
)}

View File

@@ -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={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">

View File

@@ -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 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 };
// 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 (identifier) {
rawCardList.push({
type: identifier.type,
value: identifier.value,
quantity: quantity,
finish: finish
});
// Fallback ID logic
rawCardList.push({ type: 'id', value: idMatch[0], quantity: 1 }); // Default simple UUID match
return;
}
// 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;
}
}

View File

@@ -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');
}
}