Restructure into full project layout
This commit is contained in:
@@ -0,0 +1,333 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user