`lib/use-auth.js` is now the sole client-side auth surface (P1 §9 of
`.convoys/ship-readiness.md`). The legacy `lib/auth-context.js`
(`AuthProvider` + `useAuth`) and `lib/admin-auth.js` (`AdminProvider` +
`useAdmin` + `useIsAdmin`) are deleted; every importer is migrated to
the canonical hook. Pre-convoy a worst-case page mount issued THREE
identical `GET /api/auth/verify` requests (one per provider/hook); the
post-convoy floor is one verify per page mount (3 → 1 on
`pages/card/[id].js`, 2 → 1 elsewhere).
Importer inventory swept (7 source files):
- `pages/_app.js` — removed `<AuthProvider>` wrapper; `<ThemeProvider>`
is now the only top-level provider. `lib/use-auth.js` is hook-only,
no replacement provider needed.
- `pages/index.js`, `pages/scanner.js`, `pages/decks.js`,
`pages/deck/[id].js`, `pages/deck-builder.js` — `import { useAuth }`
path swap from `../lib/auth-context` to `../lib/use-auth`. All five
pages destructured only `{ user }` or `{ user, loading }`; verified
no consumer reads `login` / `register` from useAuth (those flows are
in `pages/login.js` / `pages/signup.js` which call the API directly),
so no shape-parity gap on `lib/use-auth.js`.
- `pages/card/[id].js` — replaced `useIsAdmin()` (the only consumer of
`lib/admin-auth.js` anywhere in the tree) with synchronous
`user?.role === 'admin'` derived from the existing `useAuth()` call.
Render condition at line 524 stays byte-identical.
Decisions documented in `.convoys/single-auth-provider.md`:
- D1: no extension to `lib/use-auth.js` (zero call sites for `login` /
`register` from useAuth — those flows are direct fetches in
`login.js` / `signup.js`).
- D2: `useIsAdmin()` collapses onto `useAuth()`; no separate hook.
- D3: provider tree `<ThemeProvider><AuthProvider>{children}</AuthProvider></ThemeProvider>`
→ `<ThemeProvider>{children}</ThemeProvider>`.
- D4: 3 → 1 verify roundtrip on `card/[id].js`; 2 → 1 on every other
page-load.
- D5: zero test files modified; the 21-test vitest suite is server-
side or prop-driven (`Layout.test.js` passes `user` as a prop, never
imports the legacy hooks).
Doc / config updates so the deletion lands cleanly:
- `.github/CODEOWNERS` — drop the two CODEOWNERS lines for the deleted
files.
- `AGENTS.md` § 2 architecture row + § 3 "Auth (client)" bullet —
rewritten for the post-convoy single-surface state.
- `.cursor/rules/auth-and-permissions.mdc` — § "Legacy" reframed to
"deleted by this convoy"; § "Authentication state on the client"
updated to the post-convoy `useAuth()` shape and the direct-fetch
login flow used by `login.js` / `signup.js`.
- `.cursor/rules/no-go-zones.mdc` — auth-refactors bullet drops the
deleted files from the canonical list.
- `.cursor/skills/add-page/SKILL.md` — checklist + anti-pattern row
refer to the deletion.
Verification:
- `rg "lib/auth-context|lib/admin-auth" --type js` → 0 hits in source.
- `npm run lint` → 128 → 125 problems (3 fewer errors from the deleted
unused-import lines; no regression).
- `npm run test:run` → 21/21 pass (including the 5 Layout regression
locks from `fix-layout-default-user`, which are prop-driven and
unaffected).
- `npm run build` → all 26 pages compile end-to-end; no SSR / static-
generation breakage that would have surfaced if a page tried to use
the legacy context hook unwrapped.
- Manual smoke deferred to operator post-merge per convoy doc.
Risks (full discussion in convoy file):
- R1 shape parity gap — verified zero consumers of legacy-only
surface; mitigated.
- R2 SSR mismatch from removing `<AuthProvider>` — `useEffect`-
guarded `localStorage` read; identical SSR shape pre/post; build
passes.
- R3 missed importer — post-delete grep + build pass would surface
any miss.
- R5 stale `useAuth` cache across components — pre-existing
pattern, called out as follow-up rather than addressed here.
Out of scope: any change to `lib/permission-middleware.js` (server-
side; resolved P0 #1), `lib/auth-secret.js` (resolved P0 #2),
`pages/api/**` route handlers, login / register API contracts, or
the seeded admin account flow.
Co-authored-by: Cursor <cursoragent@cursor.com>
341 lines
No EOL
13 KiB
JavaScript
341 lines
No EOL
13 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { useRouter } from 'next/router';
|
|
import Link from 'next/link';
|
|
import Layout from '../../components/Layout';
|
|
import { ManaCost, ColorIdentity } from '../../components/ManaSymbols';
|
|
import { useAuth } from '../../lib/use-auth';
|
|
import { getColorIdentity } from '../../lib/mana-symbols';
|
|
|
|
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');
|
|
|
|
useEffect(() => {
|
|
if (deckId) {
|
|
fetchDeck();
|
|
}
|
|
}, [deckId]);
|
|
|
|
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);
|
|
}
|
|
};
|
|
|
|
const getDeckStats = () => {
|
|
if (!deck?.cards) return { totalCards: 0, avgCmc: 0, colorCounts: {}, typeCounts: {} };
|
|
|
|
const totalCards = deck.cards.reduce((sum, card) => sum + card.quantity, 0);
|
|
const avgCmc = deck.cards.length > 0
|
|
? (deck.cards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1)
|
|
: 0;
|
|
|
|
const colorCounts = deck.cards.reduce((counts, card) => {
|
|
if (card.colors) {
|
|
try {
|
|
const colors = JSON.parse(card.colors);
|
|
colors.forEach(color => {
|
|
counts[color] = (counts[color] || 0) + card.quantity;
|
|
});
|
|
} catch (e) {
|
|
// Handle non-JSON color format
|
|
}
|
|
}
|
|
return counts;
|
|
}, {});
|
|
|
|
const typeCounts = deck.cards.reduce((counts, card) => {
|
|
if (card.card_type) {
|
|
const types = card.card_type.split(' — ')[0].split(' ');
|
|
types.forEach(type => {
|
|
counts[type] = (counts[type] || 0) + card.quantity;
|
|
});
|
|
}
|
|
return counts;
|
|
}, {});
|
|
|
|
return { totalCards, avgCmc, colorCounts, typeCounts };
|
|
};
|
|
|
|
const getGroupedCards = () => {
|
|
if (!deck?.cards) return {};
|
|
|
|
return deck.cards.reduce((groups, card) => {
|
|
let key;
|
|
|
|
switch (groupBy) {
|
|
case 'type':
|
|
key = card.card_type ? card.card_type.split(' — ')[0] : 'Unknown';
|
|
break;
|
|
case 'cmc':
|
|
key = `${card.cmc || 0} Mana`;
|
|
break;
|
|
case 'color':
|
|
try {
|
|
const colors = card.colors ? JSON.parse(card.colors) : [];
|
|
key = colors.length === 0 ? 'Colorless' : colors.map(c => c).join('');
|
|
} catch (e) {
|
|
key = 'Colorless';
|
|
}
|
|
break;
|
|
case 'rarity':
|
|
key = card.rarity || 'Unknown';
|
|
break;
|
|
default:
|
|
key = 'All Cards';
|
|
}
|
|
|
|
if (!groups[key]) groups[key] = [];
|
|
groups[key].push(card);
|
|
return groups;
|
|
}, {});
|
|
};
|
|
|
|
const getFormatIcon = (format) => {
|
|
switch (format) {
|
|
case 'Commander':
|
|
return '⚔️';
|
|
case 'Standard':
|
|
return '🏆';
|
|
case 'Modern':
|
|
return '🔥';
|
|
case 'Legacy':
|
|
return '💎';
|
|
default:
|
|
return '🃏';
|
|
}
|
|
};
|
|
|
|
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 border-accent-ember"></div>
|
|
</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">Deck not found</h1>
|
|
<Link href="/decks" className="text-accent-ember hover:underline">
|
|
Back to Decks
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
const stats = getDeckStats();
|
|
const groupedCards = getGroupedCards();
|
|
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="text-accent-ember hover:underline">
|
|
← Back to Decks
|
|
</Link>
|
|
</div>
|
|
<div className="flex items-center space-x-3 mb-2">
|
|
<span className="text-3xl">{getFormatIcon(deck.format)}</span>
|
|
<h1 className="text-3xl font-bold text-text-primary">{deck.name}</h1>
|
|
{deck.is_public && (
|
|
<span className="bg-green-100 text-green-800 px-2 py-1 rounded-full text-xs font-medium">
|
|
Public
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-text-secondary mb-2">
|
|
by {deck.creator_username} • {deck.format} • {stats.totalCards} cards
|
|
</p>
|
|
{deck.description && (
|
|
<p className="text-text-secondary max-w-2xl">{deck.description}</p>
|
|
)}
|
|
</div>
|
|
|
|
{isOwner && (
|
|
<div className="flex space-x-3">
|
|
<Link
|
|
href={`/deck-builder?deck=${deck.id}`}
|
|
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
|
|
>
|
|
Edit Deck
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
|
|
{/* Stats Sidebar */}
|
|
<div className="lg:col-span-1">
|
|
<div className="bg-bg-secondary rounded-lg p-6 mb-6">
|
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Statistics</h3>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-text-secondary">Total Cards:</span>
|
|
<span className="text-text-primary font-medium">{stats.totalCards}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-secondary">Avg. CMC:</span>
|
|
<span className="text-text-primary font-medium">{stats.avgCmc}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-text-secondary">Format:</span>
|
|
<span className="text-text-primary font-medium">{deck.format}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Color Distribution */}
|
|
{Object.keys(stats.colorCounts).length > 0 && (
|
|
<div className="mt-6">
|
|
<h4 className="text-text-secondary text-sm font-medium mb-3">Color Distribution</h4>
|
|
<div className="space-y-2">
|
|
{Object.entries(stats.colorCounts)
|
|
.sort(([,a], [,b]) => b - a)
|
|
.map(([color, count]) => (
|
|
<div key={color} className="flex justify-between items-center">
|
|
<div className="flex items-center space-x-2">
|
|
<span className="text-lg">{color}</span>
|
|
<span className="text-text-secondary text-sm">{color}</span>
|
|
</div>
|
|
<span className="text-text-primary font-medium">{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Type Distribution */}
|
|
{Object.keys(stats.typeCounts).length > 0 && (
|
|
<div className="mt-6">
|
|
<h4 className="text-text-secondary text-sm font-medium mb-3">Card Types</h4>
|
|
<div className="space-y-2">
|
|
{Object.entries(stats.typeCounts)
|
|
.sort(([,a], [,b]) => b - a)
|
|
.slice(0, 8)
|
|
.map(([type, count]) => (
|
|
<div key={type} className="flex justify-between">
|
|
<span className="text-text-secondary text-sm">{type}</span>
|
|
<span className="text-text-primary font-medium">{count}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Group By Controls */}
|
|
<div className="bg-bg-secondary rounded-lg p-6">
|
|
<h3 className="text-lg font-semibold text-text-primary mb-4">Group Cards By</h3>
|
|
<div className="space-y-2">
|
|
{[
|
|
{ value: 'type', label: 'Card Type' },
|
|
{ value: 'cmc', label: 'Mana Cost' },
|
|
{ value: 'color', label: 'Color' },
|
|
{ value: 'rarity', label: 'Rarity' }
|
|
].map(option => (
|
|
<button
|
|
key={option.value}
|
|
onClick={() => setGroupBy(option.value)}
|
|
className={`w-full text-left px-3 py-2 rounded-lg transition-colors ${
|
|
groupBy === option.value
|
|
? 'bg-accent-ember text-white'
|
|
: 'text-text-secondary hover:bg-bg-tertiary'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card List */}
|
|
<div className="lg:col-span-3">
|
|
<div className="bg-bg-secondary rounded-lg 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 text-text-primary mb-2">Empty Deck</h3>
|
|
<p className="text-text-secondary">This deck doesn'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 text-text-primary mb-3 border-b border-border pb-2">
|
|
{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 bg-bg-primary rounded-lg hover:bg-bg-tertiary transition-colors">
|
|
{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 text-text-primary truncate">{card.name}</h4>
|
|
<span className="text-text-primary font-medium ml-2">{card.quantity}x</span>
|
|
</div>
|
|
<p className="text-text-secondary text-sm">{card.set_name}</p>
|
|
<div className="flex items-center space-x-2 text-xs text-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>
|
|
);
|
|
}
|