deckhearth/pages/deck/[id].js
Randall Stillwell 1f5d50c5af refactor(deck): extract grouping lib and stats sidebar (Brief 1)
Reuse computeDeckStats from deck-builder-stats; add groupDeckCards lib,
DeckDetailStatsSidebar component, and vitest coverage. Fixes stray semicolon
after useEffect. Page drops ~205 lines.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-12 21:20:48 -05:00

260 lines
No EOL
9.7 KiB
JavaScript

/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../../components/Layout';
import DeckDetailStatsSidebar from '../../components/DeckDetailStatsSidebar';
import { ManaCost } from '../../components/ManaSymbols';
import { Button } from '../../components/ui';
import { computeDeckStats } from '../../lib/deck-builder-stats.js';
import { groupDeckCards } from '../../lib/deck-detail-grouping.js';
import { getDeckFormatIcon } from '../../lib/deck-format-utils.js';
import { useAuth } from '../../lib/use-auth';
/* 2026-06-04 design-sweep pass: this page used the broken Tailwind
token classes (bg-bg-*, text-text-*, bg-accent-ember,
hover:bg-accent-ember-dark, border-accent-ember) that don't
resolve in tailwind.config.js. Sweep replaces them with inline
CSS variables + Button primitive + glass-panel surface + the
nav-item-active / nav-item-hover utilities so the page renders
with the Liquid Glass design system. */
export default function DeckDetail() {
const { user } = useAuth();
const router = useRouter();
const { id: deckId } = router.query;
const [deck, setDeck] = useState(null);
const [loading, setLoading] = useState(true);
const [groupBy, setGroupBy] = useState('type');
const fetchDeck = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = token ? { 'Authorization': `Bearer ${token}` } : {};
const response = await fetch(`/api/decks/${deckId}`, { headers });
if (response.ok) {
const data = await response.json();
setDeck(data);
} else {
console.error('Failed to fetch deck');
router.push('/decks');
}
} catch (error) {
console.error('Error fetching deck:', error);
router.push('/decks');
} finally {
setLoading(false);
}
}
useEffect(() => {
if (deckId) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- load deck when route id changes
fetchDeck();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- refetch when route deck id changes
}, [deckId]);
if (loading) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div
className="animate-spin rounded-full h-32 w-32 border-b-2"
style={{ borderColor: 'var(--accent-ember)' }}
/>
</div>
</Layout>
);
}
if (!deck) {
return (
<Layout user={user}>
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<h1
className="text-2xl font-bold mb-4"
style={{ color: 'var(--text-primary)' }}
>
Deck not found
</h1>
<Link
href="/decks"
className="hover:underline"
style={{ color: 'var(--accent-ember)' }}
>
Back to Decks
</Link>
</div>
</div>
</Layout>
);
}
const stats = computeDeckStats(deck.cards || []);
const groupedCards = groupDeckCards(deck.cards, groupBy);
const isOwner = user && deck.user_id === user.userId;
return (
<Layout user={user}>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Header */}
<div className="flex justify-between items-start mb-8">
<div>
<div className="flex items-center space-x-3 mb-2">
<Link
href="/decks"
className="hover:underline"
style={{ color: 'var(--accent-ember)' }}
>
Back to Decks
</Link>
</div>
<div className="flex items-center space-x-3 mb-2">
<span className="text-3xl">{getDeckFormatIcon(deck.format)}</span>
<h1
className="text-3xl font-bold"
style={{ color: 'var(--text-primary)' }}
>
{deck.name}
</h1>
{deck.is_public && (
<span
className="px-2 py-1 rounded-full text-xs font-medium"
style={{
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--accent-ember)',
}}
>
Public
</span>
)}
</div>
<p className="mb-2" style={{ color: 'var(--text-secondary)' }}>
by {deck.creator_username} {deck.format} {stats.totalCards} cards
</p>
{deck.description && (
<p
className="max-w-2xl"
style={{ color: 'var(--text-secondary)' }}
>
{deck.description}
</p>
)}
</div>
{isOwner && (
<div className="flex space-x-3">
<Link href={`/deck-builder?deck=${deck.id}`}>
<Button variant="primary">Edit Deck</Button>
</Link>
</div>
)}
</div>
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<DeckDetailStatsSidebar
deck={deck}
stats={stats}
groupBy={groupBy}
onGroupByChange={setGroupBy}
/>
{/* Card List */}
<div className="lg:col-span-3">
<div className="glass-panel rounded-2xl p-6">
{deck.cards && deck.cards.length === 0 ? (
<div className="text-center py-12">
<div className="text-6xl mb-4">🃏</div>
<h3
className="text-xl font-semibold mb-2"
style={{ color: 'var(--text-primary)' }}
>
Empty Deck
</h3>
<p style={{ color: 'var(--text-secondary)' }}>
This deck doesn&apos;t have any cards yet
</p>
</div>
) : (
<div className="space-y-6">
{Object.entries(groupedCards)
.sort(([a], [b]) => a.localeCompare(b))
.map(([group, cards]) => (
<div key={group}>
<h3
className="text-lg font-semibold mb-3 border-b pb-2"
style={{
color: 'var(--text-primary)',
borderColor: 'var(--border)',
}}
>
{group} ({cards.reduce((sum, card) => sum + card.quantity, 0)})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{cards
.sort((a, b) => a.name.localeCompare(b.name))
.map((card) => (
<div
key={`${card.card_id}-${card.id}`}
className="flex items-center space-x-3 p-3 rounded-xl transition-colors nav-item-hover"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-12 h-16 object-cover rounded"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<h4
className="font-medium truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</h4>
<span
className="font-medium ml-2"
style={{ color: 'var(--text-primary)' }}
>
{card.quantity}x
</span>
</div>
<p
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
>
{card.set_name}
</p>
<div
className="flex items-center space-x-2 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{card.mana_cost && (
<ManaCost cost={card.mana_cost} size="xs" />
)}
{card.rarity && (
<span className="capitalize">{card.rarity}</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}