deckhearth/pages/decks.js
Randall Stillwell 3022e80c04 refactor(auth): collapse lib/auth-context.js + lib/admin-auth.js onto lib/use-auth.js
`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>
2026-05-26 22:54:35 -05:00

461 lines
No EOL
17 KiB
JavaScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import { useAuth } from '../lib/use-auth';
export default function Decks() {
const { user } = useAuth();
const router = useRouter();
const [decks, setDecks] = useState([]);
const [loading, setLoading] = useState(true);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingDeck, setEditingDeck] = useState(null);
const [newDeck, setNewDeck] = useState({
name: '',
description: '',
format: 'Commander',
is_public: false
});
useEffect(() => {
if (user) {
fetchDecks();
}
}, [user]);
const fetchDecks = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/decks', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setDecks(data);
} else {
console.error('Failed to fetch decks');
}
} catch (error) {
console.error('Error fetching decks:', error);
} finally {
setLoading(false);
}
};
const handleCreateDeck = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem('auth_token');
const response = await fetch('/api/decks', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(newDeck)
});
if (response.ok) {
const createdDeck = await response.json();
setDecks([createdDeck, ...decks]);
setShowCreateModal(false);
setNewDeck({ name: '', description: '', format: 'Commander', is_public: false });
// Navigate to deck builder for the new deck
router.push(`/deck-builder?deck=${createdDeck.id}`);
} else {
console.error('Failed to create deck');
}
} catch (error) {
console.error('Error creating deck:', error);
}
};
const handleEditDeck = async (e) => {
e.preventDefault();
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${editingDeck.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(editingDeck)
});
if (response.ok) {
const updatedDeck = await response.json();
setDecks(decks.map(deck => deck.id === updatedDeck.id ? updatedDeck : deck));
setEditingDeck(null);
} else {
console.error('Failed to update deck');
}
} catch (error) {
console.error('Error updating deck:', error);
}
};
const handleDeleteDeck = async (deckId) => {
if (!confirm('Are you sure you want to delete this deck? This action cannot be undone.')) {
return;
}
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
setDecks(decks.filter(deck => deck.id !== deckId));
} else {
console.error('Failed to delete deck');
}
} catch (error) {
console.error('Error deleting deck:', error);
}
};
const getFormatIcon = (format) => {
switch (format) {
case 'Commander':
return '⚔️';
case 'Standard':
return '🏆';
case 'Modern':
return '🔥';
case 'Legacy':
return '💎';
default:
return '🃏';
}
};
const getFormatColor = (format) => {
switch (format) {
case 'Commander':
return 'bg-purple-100 text-purple-800';
case 'Standard':
return 'bg-blue-100 text-blue-800';
case 'Modern':
return 'bg-red-100 text-red-800';
case 'Legacy':
return 'bg-yellow-100 text-yellow-800';
default:
return 'bg-gray-100 text-gray-800';
}
};
if (!user) {
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">Please log in to view your decks</h1>
<Link href="/login" className="text-accent-ember hover:underline">
Go to Login
</Link>
</div>
</div>
</Layout>
);
}
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>
);
}
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-center mb-8">
<div>
<h1 className="text-3xl font-bold text-text-primary">My Decks</h1>
<p className="text-text-secondary mt-2">
Build and manage your MTG decks
</p>
</div>
<button
onClick={() => setShowCreateModal(true)}
className="bg-accent-ember text-white px-6 py-3 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create New Deck
</button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">{decks.length}</div>
<div className="text-text-secondary">Total Decks</div>
</div>
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">
{decks.filter(d => d.format === 'Commander').length}
</div>
<div className="text-text-secondary">Commander</div>
</div>
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">
{decks.filter(d => d.is_public).length}
</div>
<div className="text-text-secondary">Public</div>
</div>
<div className="bg-bg-secondary rounded-lg p-6">
<div className="text-2xl font-bold text-text-primary">
{decks.reduce((sum, deck) => sum + (deck.card_count || 0), 0)}
</div>
<div className="text-text-secondary">Total Cards</div>
</div>
</div>
{/* Decks Grid */}
{decks.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">No decks yet</h3>
<p className="text-text-secondary mb-6">Create your first deck to get started</p>
<button
onClick={() => setShowCreateModal(true)}
className="bg-accent-ember text-white px-6 py-3 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create Your First Deck
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{decks.map((deck) => (
<div key={deck.id} className="bg-bg-secondary rounded-lg p-6 hover:shadow-lg transition-shadow">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center space-x-2">
<span className="text-2xl">{getFormatIcon(deck.format)}</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getFormatColor(deck.format)}`}>
{deck.format}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => setEditingDeck({...deck})}
className="text-text-secondary hover:text-accent-ember transition-colors"
>
</button>
<button
onClick={() => handleDeleteDeck(deck.id)}
className="text-text-secondary hover:text-red-500 transition-colors"
>
🗑
</button>
</div>
</div>
<h3 className="text-xl font-bold text-text-primary mb-2">{deck.name}</h3>
{deck.description && (
<p className="text-text-secondary text-sm mb-4 line-clamp-2">{deck.description}</p>
)}
<div className="flex justify-between items-center text-sm text-text-secondary mb-4">
<span>{deck.card_count || 0} cards</span>
{deck.is_public && <span className="text-green-600">Public</span>}
</div>
<div className="flex space-x-2">
<Link
href={`/deck-builder?deck=${deck.id}`}
className="flex-1 bg-accent-ember text-white text-center py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Edit Deck
</Link>
<Link
href={`/deck/${deck.id}`}
className="flex-1 bg-bg-tertiary text-text-primary text-center py-2 rounded-lg hover:bg-bg-primary transition-colors"
>
View
</Link>
</div>
</div>
))}
</div>
)}
{/* Create Deck Modal */}
{showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">Create New Deck</h2>
<form onSubmit={handleCreateDeck}>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Deck Name *
</label>
<input
type="text"
required
value={newDeck.name}
onChange={(e) => setNewDeck({...newDeck, name: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
placeholder="Enter deck name"
/>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Format
</label>
<select
value={newDeck.format}
onChange={(e) => setNewDeck({...newDeck, format: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Description
</label>
<textarea
value={newDeck.description}
onChange={(e) => setNewDeck({...newDeck, description: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
placeholder="Describe your deck strategy..."
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={newDeck.is_public}
onChange={(e) => setNewDeck({...newDeck, is_public: e.target.checked})}
className="mr-2"
/>
<span className="text-text-secondary text-sm">Make deck public</span>
</label>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setShowCreateModal(false)}
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Create Deck
</button>
</div>
</form>
</div>
</div>
)}
{/* Edit Deck Modal */}
{editingDeck && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4 z-50">
<div className="bg-bg-primary rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold text-text-primary mb-4">Edit Deck</h2>
<form onSubmit={handleEditDeck}>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Deck Name *
</label>
<input
type="text"
required
value={editingDeck.name}
onChange={(e) => setEditingDeck({...editingDeck, name: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
/>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Format
</label>
<select
value={editingDeck.format}
onChange={(e) => setEditingDeck({...editingDeck, format: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div className="mb-4">
<label className="block text-text-secondary text-sm font-medium mb-2">
Description
</label>
<textarea
value={editingDeck.description || ''}
onChange={(e) => setEditingDeck({...editingDeck, description: e.target.value})}
className="w-full px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-secondary text-text-primary"
rows="3"
/>
</div>
<div className="mb-6">
<label className="flex items-center">
<input
type="checkbox"
checked={editingDeck.is_public}
onChange={(e) => setEditingDeck({...editingDeck, is_public: e.target.checked})}
className="mr-2"
/>
<span className="text-text-secondary text-sm">Make deck public</span>
</label>
</div>
<div className="flex space-x-3">
<button
type="button"
onClick={() => setEditingDeck(null)}
className="flex-1 px-4 py-2 border border-border rounded-lg text-text-secondary hover:bg-bg-secondary transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex-1 bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Save Changes
</button>
</div>
</form>
</div>
</div>
)}
</div>
</Layout>
);
}