Files
Commander-Deck-App-backup/frontend/src/pages/collection/CollectionPage.tsx
T

334 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useRef } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Upload, Package, Search, Trash2, RefreshCw, X, ChevronLeft, ChevronRight } from 'lucide-react'
import { collectionApi } from '@/api'
import { Button, Input, Panel, Badge, Spinner, EmptyState, Skeleton, ErrorMessage } from '@/components/ui'
import type { CollectionCard, CollectionStats } from '@/types'
import { AxiosError } from 'axios'
// ─── Import panel ─────────────────────────────────────────────────────────────
function ImportPanel({ onSuccess }: { onSuccess: () => void }) {
const [source, setSource] = useState<'archidekt' | 'manabox'>('archidekt')
const [replace, setReplace] = useState(false)
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const fileRef = useRef<HTMLInputElement>(null)
const importMutation = useMutation({
mutationFn: () => collectionApi.import(source, file!, replace),
onSuccess: (res) => {
setSuccess(`Imported ${res.data.imported ?? 'cards'} successfully.`)
setFile(null)
setError('')
onSuccess()
},
onError: (err) => {
if (err instanceof AxiosError) {
setError(err.response?.data?.detail ?? 'Import failed.')
} else {
setError('Import failed.')
}
},
})
return (
<Panel className="p-5 flex flex-col gap-4">
<h2 className="font-display text-base text-text-primary">Import collection</h2>
{/* Source selector */}
<div className="flex gap-2">
{(['archidekt', 'manabox'] as const).map((s) => (
<button
key={s}
onClick={() => setSource(s)}
className={`flex-1 py-2 rounded text-sm font-medium transition-all capitalize ${
source === s
? 'bg-accent-violet text-white'
: 'bg-bg-elevated text-text-muted hover:text-text-primary border border-bg-border'
}`}
>
{s}
</button>
))}
</div>
{/* File drop zone */}
<div
onClick={() => fileRef.current?.click()}
className={`
border-2 border-dashed rounded-lg p-6 text-center cursor-pointer transition-all
${file ? 'border-accent-violet/50 bg-accent-violet/5' : 'border-bg-border hover:border-accent-violet/40 hover:bg-bg-elevated'}
`}
>
<input
ref={fileRef}
type="file"
accept=".csv,.json"
className="hidden"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
{file ? (
<div className="flex items-center justify-center gap-2">
<span className="text-sm text-accent-violet-light">{file.name}</span>
<button
onClick={(e) => { e.stopPropagation(); setFile(null) }}
className="text-text-muted hover:text-danger"
>
<X className="w-4 h-4" />
</button>
</div>
) : (
<div className="flex flex-col items-center gap-2 text-text-muted">
<Upload className="w-6 h-6" />
<p className="text-sm">Click to choose {source === 'archidekt' ? 'CSV or JSON' : 'CSV'} file</p>
</div>
)}
</div>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={replace}
onChange={(e) => setReplace(e.target.checked)}
className="rounded border-bg-border"
/>
<span className="text-sm text-text-secondary">Replace existing collection</span>
</label>
{error && <ErrorMessage message={error} />}
{success && (
<div className="bg-owned/10 border border-owned/30 rounded-md p-3 text-sm text-owned">
{success}
</div>
)}
<Button
loading={importMutation.isPending}
disabled={!file}
icon={<Upload className="w-4 h-4" />}
onClick={() => importMutation.mutate()}
>
Import
</Button>
</Panel>
)
}
// ─── Stats panel ──────────────────────────────────────────────────────────────
function StatsPanel() {
const { data } = useQuery<CollectionStats>({
queryKey: ['collection-stats'],
queryFn: async () => (await collectionApi.stats()).data,
})
if (!data) return null
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{[
{ label: 'Total cards', value: data.total_cards.toLocaleString() },
{ label: 'Unique cards', value: data.unique_cards.toLocaleString() },
{ label: 'Foil', value: data.foil_cards.toLocaleString() },
{
label: 'Est. value',
value: data.estimated_value != null ? `$${data.estimated_value.toFixed(2)}` : '—',
},
].map((s) => (
<Panel key={s.label} className="p-3 text-center">
<p className="text-xl font-semibold text-text-primary">{s.value}</p>
<p className="text-xs text-text-muted mt-0.5">{s.label}</p>
</Panel>
))}
</div>
)
}
// ─── Card row ─────────────────────────────────────────────────────────────────
function CollectionCardRow({
card,
onDelete,
}: {
card: CollectionCard
onDelete: (id: number) => void
}) {
const img = card.scryfall_data?.image_uris?.small
const price = card.scryfall_data?.prices?.usd
return (
<div className="flex items-center gap-3 py-2.5 px-3 hover:bg-bg-elevated rounded-md transition-colors group">
{img ? (
<img src={img} alt={card.card_name} className="w-7 h-9 rounded object-cover flex-shrink-0" />
) : (
<div className="w-7 h-9 bg-bg-border rounded flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm text-text-primary truncate">{card.card_name}</p>
<div className="flex gap-2 items-center mt-0.5">
{card.set_code && (
<span className="text-xs text-text-muted uppercase">{card.set_code}</span>
)}
{card.collector_number && (
<span className="text-xs text-text-muted">#{card.collector_number}</span>
)}
</div>
</div>
<div className="flex items-center gap-3 flex-shrink-0">
<div className="text-right">
{card.quantity > 0 && (
<span className="text-xs text-text-secondary">{card.quantity}×</span>
)}
{card.foil_quantity > 0 && (
<span className="text-xs text-unowned ml-1.5">{card.foil_quantity}</span>
)}
</div>
{price && <span className="text-xs text-text-muted">${price}</span>}
<button
onClick={() => onDelete(card.id)}
className="text-text-muted hover:text-danger opacity-0 group-hover:opacity-100 transition-all"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export function CollectionPage() {
const [search, setSearch] = useState('')
const [page, setPage] = useState(1)
const queryClient = useQueryClient()
const { data, isLoading } = useQuery({
queryKey: ['collection', search, page],
queryFn: async () => (await collectionApi.list(search, page)).data,
})
const deleteMutation = useMutation({
mutationFn: (id: number) => collectionApi.deleteCard(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collection'] })
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
},
})
const clearMutation = useMutation({
mutationFn: () => collectionApi.clear(),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['collection'] })
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
},
})
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['collection'] })
queryClient.invalidateQueries({ queryKey: ['collection-stats'] })
}
const cards: CollectionCard[] = data?.items ?? []
const total: number = data?.total ?? 0
const pages: number = data?.pages ?? 1
return (
<div className="max-w-3xl mx-auto px-4 py-8">
<div className="flex items-center justify-between mb-6">
<h1 className="font-display text-2xl text-text-primary">Collection</h1>
{total > 0 && (
<Button
variant="danger"
size="sm"
icon={<Trash2 className="w-4 h-4" />}
loading={clearMutation.isPending}
onClick={() => {
if (confirm('Clear your entire collection? This cannot be undone.')) {
clearMutation.mutate()
}
}}
>
Clear all
</Button>
)}
</div>
<div className="flex flex-col gap-6">
<ImportPanel onSuccess={invalidate} />
<StatsPanel />
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-text-muted" />
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
placeholder="Search collection…"
className="w-full bg-bg-elevated border border-bg-border rounded-md pl-9 pr-3 py-2 text-sm text-text-primary placeholder:text-text-muted focus:outline-none focus:ring-2 focus:ring-accent-violet focus:border-transparent"
/>
</div>
{/* Card list */}
<Panel className="overflow-hidden">
{isLoading ? (
<div className="p-4 flex flex-col gap-2">
{[1, 2, 3, 4, 5].map((n) => (
<div key={n} className="flex gap-3 items-center">
<Skeleton className="w-7 h-9 rounded" />
<div className="flex-1">
<Skeleton className="h-4 w-40 mb-1.5" />
<Skeleton className="h-3 w-20" />
</div>
</div>
))}
</div>
) : cards.length === 0 ? (
<EmptyState
icon={<Package className="w-full h-full" />}
title={search ? 'No matching cards' : 'Collection is empty'}
description={search ? 'Try a different search.' : 'Import your collection above.'}
/>
) : (
<div className="divide-y divide-bg-border px-1">
{cards.map((card) => (
<CollectionCardRow
key={card.id}
card={card}
onDelete={(id) => deleteMutation.mutate(id)}
/>
))}
</div>
)}
</Panel>
{/* Pagination */}
{pages > 1 && (
<div className="flex items-center justify-between text-sm text-text-muted">
<span>{total} cards</span>
<div className="flex items-center gap-2">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="p-1 hover:text-text-primary disabled:opacity-30"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span>Page {page} of {pages}</span>
<button
onClick={() => setPage((p) => Math.min(pages, p + 1))}
disabled={page === pages}
className="p-1 hover:text-text-primary disabled:opacity-30"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
</div>
)
}