Compare commits
3 Commits
260920184d
...
dcbc484a1c
| Author | SHA1 | Date | |
|---|---|---|---|
| dcbc484a1c | |||
| 618a2dd09d | |||
| 8433d02e5b |
@@ -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.
|
||||||
@@ -7,8 +7,32 @@ import { DeckTester } from './modules/tester/DeckTester';
|
|||||||
import { Pack } from './services/PackGeneratorService';
|
import { Pack } from './services/PackGeneratorService';
|
||||||
|
|
||||||
export const App: React.FC = () => {
|
export const App: React.FC = () => {
|
||||||
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>('draft');
|
const [activeTab, setActiveTab] = useState<'draft' | 'bracket' | 'lobby' | 'tester'>(() => {
|
||||||
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>([]);
|
const saved = localStorage.getItem('activeTab');
|
||||||
|
return (saved as 'draft' | 'bracket' | 'lobby' | 'tester') || 'draft';
|
||||||
|
});
|
||||||
|
|
||||||
|
const [generatedPacks, setGeneratedPacks] = useState<Pack[]>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('generatedPacks');
|
||||||
|
return saved ? JSON.parse(saved) : [];
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to load packs from storage", e);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem('activeTab', activeTab);
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('generatedPacks', JSON.stringify(generatedPacks));
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to save packs to storage", e);
|
||||||
|
}
|
||||||
|
}, [generatedPacks]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
|
<div className="min-h-screen bg-slate-900 text-slate-100 font-sans pb-20">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { DraftCard, Pack } from '../services/PackGeneratorService';
|
import { DraftCard, Pack } from '../services/PackGeneratorService';
|
||||||
import { Copy } from 'lucide-react';
|
import { Copy } from 'lucide-react';
|
||||||
import { StackView } from './StackView';
|
import { StackView } from './StackView';
|
||||||
@@ -8,7 +8,87 @@ interface PackCardProps {
|
|||||||
viewMode: 'list' | 'grid' | 'stack';
|
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 ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
||||||
|
const isFoil = (card: DraftCard) => card.finish === 'foil';
|
||||||
|
|
||||||
const getRarityColorClass = (rarity: string) => {
|
const getRarityColorClass = (rarity: string) => {
|
||||||
switch (rarity) {
|
switch (rarity) {
|
||||||
case 'common': return 'bg-black text-white border-slate-600';
|
case 'common': return 'bg-black text-white border-slate-600';
|
||||||
@@ -20,21 +100,19 @@ const ListItem: React.FC<{ card: DraftCard }> = ({ card }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li className="relative group">
|
<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">
|
<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 ${card.rarity === 'mythic' ? 'text-orange-400' : card.rarity === 'rare' ? 'text-yellow-400' : card.rarity === 'uncommon' ? 'text-slate-200' : 'text-slate-400'}`}>
|
<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}
|
{card.name}
|
||||||
|
{isFoil(card) && (
|
||||||
|
<span className="text-transparent bg-clip-text bg-gradient-to-r from-pink-300 via-purple-300 to-indigo-400 animate-pulse text-xs font-bold border border-purple-500/50 rounded px-1">
|
||||||
|
FOIL
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
|
<span className={`w-2 h-2 rounded-full border ${getRarityColorClass(card.rarity)} !p-0 !text-[0px]`}></span>
|
||||||
</div>
|
</div>
|
||||||
{card.image && (
|
</CardHoverWrapper>
|
||||||
<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">
|
|
||||||
<img src={card.image} alt={card.name} className="w-full rounded" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</li>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,10 +122,11 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
|||||||
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
|
const uncommons = pack.cards.filter(c => c.rarity === 'uncommon');
|
||||||
const commons = pack.cards.filter(c => c.rarity === 'common');
|
const commons = pack.cards.filter(c => c.rarity === 'common');
|
||||||
|
|
||||||
|
const isFoil = (card: DraftCard) => card.finish === 'foil';
|
||||||
|
|
||||||
const copyPackToClipboard = () => {
|
const copyPackToClipboard = () => {
|
||||||
const text = pack.cards.map(c => c.name).join('\n');
|
const text = pack.cards.map(c => c.name).join('\n');
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
// Toast notification could go here
|
|
||||||
alert(`Pack list ${pack.id} copied!`);
|
alert(`Pack list ${pack.id} copied!`);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,20 +174,29 @@ export const PackCard: React.FC<PackCardProps> = ({ pack, viewMode }) => {
|
|||||||
{viewMode === 'grid' && (
|
{viewMode === 'grid' && (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||||
{pack.cards.map((card) => (
|
{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 border-slate-800">
|
<CardHoverWrapper key={card.id} card={card}>
|
||||||
{card.image ? (
|
<div className="relative group bg-slate-900 rounded-lg">
|
||||||
<img src={card.image} alt={card.name} className="w-full h-full object-cover" />
|
{/* 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'}`}>
|
||||||
<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">
|
{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" />}
|
||||||
{card.name}
|
{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}
|
||||||
|
</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>
|
||||||
)}
|
</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' :
|
</CardHoverWrapper>
|
||||||
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
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, Trash2 } from 'lucide-react';
|
||||||
import { CardParserService } from '../../services/CardParserService';
|
import { CardParserService } from '../../services/CardParserService';
|
||||||
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
import { ScryfallService, ScryfallCard, ScryfallSet } from '../../services/ScryfallService';
|
||||||
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
import { PackGeneratorService, ProcessedPools, SetsMap, Pack, PackGenerationSettings } from '../../services/PackGeneratorService';
|
||||||
@@ -20,32 +20,91 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
const generatorService = React.useMemo(() => new PackGeneratorService(), []);
|
||||||
|
|
||||||
// --- State ---
|
// --- State ---
|
||||||
const [inputText, setInputText] = useState('');
|
const [inputText, setInputText] = useState(() => localStorage.getItem('cube_inputText') || '');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [progress, setProgress] = useState('');
|
const [progress, setProgress] = useState('');
|
||||||
|
const [copySuccess, setCopySuccess] = useState(false);
|
||||||
|
|
||||||
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(null);
|
const [rawScryfallData, setRawScryfallData] = useState<ScryfallCard[] | null>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('cube_rawScryfallData');
|
||||||
|
return saved ? JSON.parse(saved) : null;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to load rawScryfallData from local storage", e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (rawScryfallData) {
|
||||||
|
localStorage.setItem('cube_rawScryfallData', JSON.stringify(rawScryfallData));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('cube_rawScryfallData');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("Failed to save rawScryfallData to local storage (likely quota exceeded)", e);
|
||||||
|
}
|
||||||
|
}, [rawScryfallData]);
|
||||||
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
|
const [processedData, setProcessedData] = useState<{ pools: ProcessedPools, sets: SetsMap } | null>(null);
|
||||||
|
|
||||||
const [filters, setFilters] = useState({
|
const [filters, setFilters] = useState<{
|
||||||
ignoreBasicLands: true,
|
ignoreBasicLands: boolean;
|
||||||
ignoreCommander: true,
|
ignoreCommander: boolean;
|
||||||
ignoreTokens: true
|
ignoreTokens: boolean;
|
||||||
|
}>(() => {
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('cube_filters');
|
||||||
|
return saved ? JSON.parse(saved) : {
|
||||||
|
ignoreBasicLands: true,
|
||||||
|
ignoreCommander: true,
|
||||||
|
ignoreTokens: true
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ignoreBasicLands: true,
|
||||||
|
ignoreCommander: true,
|
||||||
|
ignoreTokens: true
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
|
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'stack'>('list');
|
||||||
|
|
||||||
// Generation Settings
|
// Generation Settings
|
||||||
const [genSettings, setGenSettings] = useState<PackGenerationSettings>({
|
const [genSettings, setGenSettings] = useState<PackGenerationSettings>(() => {
|
||||||
mode: 'mixed',
|
try {
|
||||||
rarityMode: 'peasant'
|
const saved = localStorage.getItem('cube_genSettings');
|
||||||
|
return saved ? JSON.parse(saved) : {
|
||||||
|
mode: 'mixed',
|
||||||
|
rarityMode: 'peasant'
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
mode: 'mixed',
|
||||||
|
rarityMode: 'peasant'
|
||||||
|
};
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const [sourceMode, setSourceMode] = useState<'upload' | 'set'>('upload');
|
const [sourceMode, setSourceMode] = useState<'upload' | 'set'>(() =>
|
||||||
|
(localStorage.getItem('cube_sourceMode') as 'upload' | 'set') || 'upload'
|
||||||
|
);
|
||||||
const [availableSets, setAvailableSets] = useState<ScryfallSet[]>([]);
|
const [availableSets, setAvailableSets] = useState<ScryfallSet[]>([]);
|
||||||
const [selectedSet, setSelectedSet] = useState<string>('');
|
const [selectedSet, setSelectedSet] = useState(() => localStorage.getItem('cube_selectedSet') || '');
|
||||||
const [numBoxes, setNumBoxes] = useState<number>(3);
|
const [numBoxes, setNumBoxes] = useState<number>(() => {
|
||||||
|
const saved = localStorage.getItem('cube_numBoxes');
|
||||||
|
return saved ? parseInt(saved) : 3;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Persistence Effects ---
|
||||||
|
useEffect(() => localStorage.setItem('cube_inputText', inputText), [inputText]);
|
||||||
|
useEffect(() => localStorage.setItem('cube_filters', JSON.stringify(filters)), [filters]);
|
||||||
|
useEffect(() => localStorage.setItem('cube_genSettings', JSON.stringify(genSettings)), [genSettings]);
|
||||||
|
useEffect(() => localStorage.setItem('cube_sourceMode', sourceMode), [sourceMode]);
|
||||||
|
useEffect(() => localStorage.setItem('cube_selectedSet', selectedSet), [selectedSet]);
|
||||||
|
useEffect(() => localStorage.setItem('cube_numBoxes', numBoxes.toString()), [numBoxes]);
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@@ -73,36 +132,7 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
event.target.value = '';
|
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 () => {
|
const fetchAndParse = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -129,7 +159,14 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
identifiers.forEach(id => {
|
identifiers.forEach(id => {
|
||||||
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
const card = scryfallService.getCachedCard(id.type === 'id' ? { id: id.value } : { name: id.value });
|
||||||
if (card) {
|
if (card) {
|
||||||
for (let i = 0; i < id.quantity; i++) expandedCards.push(card);
|
for (let i = 0; i < id.quantity; i++) {
|
||||||
|
// Clone card to attach unique properties like finish
|
||||||
|
const expandedCard = { ...card };
|
||||||
|
if (id.finish) {
|
||||||
|
expandedCard.finish = id.finish;
|
||||||
|
}
|
||||||
|
expandedCards.push(expandedCard);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -175,10 +212,82 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
}, 50);
|
}, 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) => {
|
const toggleFilter = (key: keyof typeof filters) => {
|
||||||
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
setFilters(prev => ({ ...prev, [key]: !prev[key] }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
if (window.confirm("Are you sure you want to clear this session? All parsed cards and generated packs will be lost.")) {
|
||||||
|
setPacks([]);
|
||||||
|
setInputText('');
|
||||||
|
setRawScryfallData(null);
|
||||||
|
setProcessedData(null);
|
||||||
|
setSelectedSet('');
|
||||||
|
localStorage.removeItem('cube_inputText');
|
||||||
|
localStorage.removeItem('cube_rawScryfallData');
|
||||||
|
localStorage.removeItem('cube_selectedSet');
|
||||||
|
// We keep filters and settings as they are user preferences
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
|
<div className="max-w-7xl mx-auto grid grid-cols-1 lg:grid-cols-12 gap-8 p-4 md:p-6">
|
||||||
|
|
||||||
@@ -218,7 +327,9 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
<Upload className="w-3 h-3" /> Upload
|
<Upload className="w-3 h-3" /> Upload
|
||||||
</button>
|
</button>
|
||||||
<input type="file" ref={fileInputRef} className="hidden" accept=".csv,.txt" onChange={handleFileUpload} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -369,6 +480,15 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
|
{loading ? <Loader2 className="w-5 h-5 animate-spin" /> : <RotateCcw className="w-5 h-5" />}
|
||||||
{loading ? 'Generating...' : '2. Generate Packs'}
|
{loading ? 'Generating...' : '2. Generate Packs'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Reset Button */}
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="w-full mt-4 py-2 text-xs font-semibold text-slate-500 hover:text-red-400 hover:bg-red-900/10 rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||||
|
title="Clear all data and start over"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3 h-3" /> Clear Session
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -385,12 +505,29 @@ export const CubeManager: React.FC<CubeManagerProps> = ({ packs, setPacks, onGoT
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{/* Play Button */}
|
{/* Play Button */}
|
||||||
{packs.length > 0 && (
|
{packs.length > 0 && (
|
||||||
<button
|
<>
|
||||||
onClick={onGoToLobby}
|
<button
|
||||||
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"
|
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>
|
<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">
|
<div className="flex bg-slate-800 rounded-lg p-1 border border-slate-700">
|
||||||
|
|||||||
@@ -11,11 +11,22 @@ interface LobbyManagerProps {
|
|||||||
|
|
||||||
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) => {
|
export const LobbyManager: React.FC<LobbyManagerProps> = ({ generatedPacks }) => {
|
||||||
const [activeRoom, setActiveRoom] = useState<any>(null);
|
const [activeRoom, setActiveRoom] = useState<any>(null);
|
||||||
const [playerName, setPlayerName] = useState('');
|
const [playerName, setPlayerName] = useState(() => localStorage.getItem('player_name') || '');
|
||||||
const [joinRoomId, setJoinRoomId] = useState('');
|
const [joinRoomId, setJoinRoomId] = useState('');
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [playerId] = useState(() => Math.random().toString(36).substring(2) + Date.now().toString(36)); // Simple persistent ID
|
const [playerId] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('player_id');
|
||||||
|
if (saved) return saved;
|
||||||
|
const newId = Math.random().toString(36).substring(2) + Date.now().toString(36);
|
||||||
|
localStorage.setItem('player_id', newId);
|
||||||
|
return newId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Persist player name
|
||||||
|
React.useEffect(() => {
|
||||||
|
localStorage.setItem('player_name', playerName);
|
||||||
|
}, [playerName]);
|
||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (!socketService.socket.connected) {
|
if (!socketService.socket.connected) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ export interface CardIdentifier {
|
|||||||
type: 'id' | 'name';
|
type: 'id' | 'name';
|
||||||
value: string;
|
value: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
|
finish?: 'foil' | 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CardParserService {
|
export class CardParserService {
|
||||||
@@ -11,54 +12,102 @@ export class CardParserService {
|
|||||||
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
const uuidRegex = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i;
|
||||||
|
|
||||||
lines.forEach(line => {
|
lines.forEach(line => {
|
||||||
if (line.toLowerCase().startsWith('quantity') || line.toLowerCase().startsWith('count,name')) return;
|
// Skip header
|
||||||
|
if (line.toLowerCase().startsWith('quantity') && line.toLowerCase().includes('name')) return;
|
||||||
|
|
||||||
const idMatch = line.match(uuidRegex);
|
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;
|
|
||||||
|
|
||||||
let identifier: { type: 'id' | 'name', value: string } | null = null;
|
|
||||||
|
|
||||||
if (idMatch) {
|
if (idMatch) {
|
||||||
identifier = { type: 'id', value: idMatch[0] };
|
// Extract quantity if present before ID, otherwise default to 1
|
||||||
} else {
|
// Simple check: Look for "Nx ID" or "N, ID" pattern?
|
||||||
const cleanLine = line.replace(/['"]/g, '');
|
// The previous/standard logic usually treats ID lines as 1x unless specified.
|
||||||
// Remove leading quantity
|
// Let's try to find a quantity at the start if it exists differently from UUID.
|
||||||
let name = cleanLine.replace(/^(\d+)[xX\s,;]+/, '').trim();
|
// But usually UUID lines are direct from export.
|
||||||
|
|
||||||
// Remove set codes in parentheses/brackets e.g. (M20), [STA]
|
// But our CSV template puts ID at the end.
|
||||||
// This regex looks for ( starts, anything inside, ) ends, or same for []
|
// If UUID is present anywhere in the line, we might trust it over the name.
|
||||||
name = name.replace(/\s*[\(\[].*?[\)\]]/g, '');
|
// 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)
|
// Let's parse with CSV logic first.
|
||||||
name = name.replace(/\s+\d+$/, '');
|
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
|
// If the last part has UUID, use it.
|
||||||
name = name.replace(/^[,;]+|[,;]+$/g, '').trim();
|
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
|
// Fallback ID logic
|
||||||
if (name.includes(',')) name = name.split(',')[0].trim();
|
rawCardList.push({ type: 'id', value: idMatch[0], quantity: 1 }); // Default simple UUID match
|
||||||
|
return;
|
||||||
if (name && name.length > 1) identifier = { type: 'name', value: name };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (identifier) {
|
// Not an ID match, try parsing as name
|
||||||
// Return one entry per quantity? Or aggregated?
|
const parts = this.parseCsvLine(line);
|
||||||
// The original code pushed multiple entries to an array.
|
|
||||||
// For a parser service, returning the count is better, but to match logic:
|
|
||||||
// "for (let i = 0; i < quantity; i++) rawCardList.push(identifier);"
|
|
||||||
// I will return one object with Quantity property to be efficient.
|
|
||||||
|
|
||||||
rawCardList.push({
|
if (parts.length >= 2 && !isNaN(parseInt(parts[0]))) {
|
||||||
type: identifier.type,
|
// It looks like result of our CSV: Quantity, Name, Finish, ...
|
||||||
value: identifier.value,
|
const quantity = parseInt(parts[0]);
|
||||||
quantity: quantity
|
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.");
|
if (rawCardList.length === 0) throw new Error("No valid cards found.");
|
||||||
return rawCardList;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface DraftCard {
|
|||||||
set: string;
|
set: string;
|
||||||
setCode: string;
|
setCode: string;
|
||||||
setType: string;
|
setType: string;
|
||||||
|
finish?: 'foil' | 'normal';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Pack {
|
export interface Pack {
|
||||||
@@ -71,7 +72,8 @@ export class PackGeneratorService {
|
|||||||
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
|
image: cardData.image_uris?.normal || cardData.card_faces?.[0]?.image_uris?.normal || '',
|
||||||
set: cardData.set_name,
|
set: cardData.set_name,
|
||||||
setCode: cardData.set,
|
setCode: cardData.set,
|
||||||
setType: setType
|
setType: setType,
|
||||||
|
finish: cardData.finish
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add to pools
|
// Add to pools
|
||||||
@@ -309,4 +311,17 @@ export class PackGeneratorService {
|
|||||||
return v.toString(16);
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface ScryfallCard {
|
|||||||
colors?: string[];
|
colors?: string[];
|
||||||
image_uris?: { normal: string };
|
image_uris?: { normal: string };
|
||||||
card_faces?: { image_uris: { normal: string } }[];
|
card_faces?: { image_uris: { normal: string } }[];
|
||||||
|
finish?: 'foil' | 'normal'; // Manual override from import
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ScryfallService {
|
export class ScryfallService {
|
||||||
|
|||||||
Reference in New Issue
Block a user