refactor(deck): useDeckDetail + DeckDetailView (Brief 3)
Extract detail page state into useDeckDetail and layout into DeckDetailView; pages/deck/[id].js is a thin loading/not-found gated composer. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7e0e6bffc1
commit
3da66d9b29
3 changed files with 158 additions and 130 deletions
75
components/DeckDetailView.js
Normal file
75
components/DeckDetailView.js
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
import Link from 'next/link';
|
||||||
|
import DeckDetailCardList from './DeckDetailCardList';
|
||||||
|
import DeckDetailStatsSidebar from './DeckDetailStatsSidebar';
|
||||||
|
import { Button } from './ui';
|
||||||
|
import { getDeckFormatIcon } from '../lib/deck-format-utils.js';
|
||||||
|
|
||||||
|
export default function DeckDetailView({
|
||||||
|
deck,
|
||||||
|
groupBy,
|
||||||
|
groupedCards,
|
||||||
|
isOwner,
|
||||||
|
setGroupBy,
|
||||||
|
stats,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<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}
|
||||||
|
/>
|
||||||
|
<DeckDetailCardList cards={deck.cards} groupedCards={groupedCards} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
lib/use-deck-detail.js
Normal file
74
lib/use-deck-detail.js
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import { useRouter } from 'next/router';
|
||||||
|
import { computeDeckStats } from './deck-builder-stats.js';
|
||||||
|
import { groupDeckCards } from './deck-detail-grouping.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deck detail page state and derived data (god-component split).
|
||||||
|
*/
|
||||||
|
export function useDeckDetail({ user = null, authLoading = true } = {}) {
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const stats = useMemo(
|
||||||
|
() => (deck ? computeDeckStats(deck.cards || []) : null),
|
||||||
|
[deck]
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupedCards = useMemo(
|
||||||
|
() => (deck ? groupDeckCards(deck.cards, groupBy) : {}),
|
||||||
|
[deck, groupBy]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isOwner = Boolean(user && deck && deck.user_id === user.userId);
|
||||||
|
const showDeckLoading = loading;
|
||||||
|
const showDeckNotFound = !loading && !deck;
|
||||||
|
|
||||||
|
return {
|
||||||
|
authLoading,
|
||||||
|
deck,
|
||||||
|
deckId,
|
||||||
|
groupBy,
|
||||||
|
groupedCards,
|
||||||
|
isOwner,
|
||||||
|
setGroupBy,
|
||||||
|
showDeckLoading,
|
||||||
|
showDeckNotFound,
|
||||||
|
stats,
|
||||||
|
user,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,63 +1,14 @@
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useRouter } from 'next/router';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Layout from '../../components/Layout';
|
import Layout from '../../components/Layout';
|
||||||
import DeckDetailCardList from '../../components/DeckDetailCardList';
|
import DeckDetailView from '../../components/DeckDetailView';
|
||||||
import DeckDetailStatsSidebar from '../../components/DeckDetailStatsSidebar';
|
|
||||||
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';
|
import { useAuth } from '../../lib/use-auth';
|
||||||
|
import { useDeckDetail } from '../../lib/use-deck-detail.js';
|
||||||
/* 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() {
|
export default function DeckDetail() {
|
||||||
const { user } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
const router = useRouter();
|
const detail = useDeckDetail({ user, authLoading });
|
||||||
const { id: deckId } = router.query;
|
|
||||||
|
|
||||||
const [deck, setDeck] = useState(null);
|
if (detail.showDeckLoading) {
|
||||||
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 (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
|
@ -70,15 +21,12 @@ export default function DeckDetail() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!deck) {
|
if (detail.showDeckNotFound) {
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1
|
<h1 className="text-2xl font-bold mb-4" style={{ color: 'var(--text-primary)' }}>
|
||||||
className="text-2xl font-bold mb-4"
|
|
||||||
style={{ color: 'var(--text-primary)' }}
|
|
||||||
>
|
|
||||||
Deck not found
|
Deck not found
|
||||||
</h1>
|
</h1>
|
||||||
<Link
|
<Link
|
||||||
|
|
@ -94,78 +42,9 @@ export default function DeckDetail() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = computeDeckStats(deck.cards || []);
|
|
||||||
const groupedCards = groupDeckCards(deck.cards, groupBy);
|
|
||||||
const isOwner = user && deck.user_id === user.userId;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Layout user={user}>
|
<Layout user={user}>
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
<DeckDetailView {...detail} />
|
||||||
{/* 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}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<DeckDetailCardList cards={deck.cards} groupedCards={groupedCards} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Layout>
|
</Layout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Loading…
Reference in a new issue