deckhearth/pages/decks.js
Randall Stillwell 0f6bfbb7df feat(pages): pass user explicitly + null-guard leaky page seeds (Brief 2 of fix-layout-default-user)
Closes the page-side half of P0 #7 from .convoys/ship-readiness.md.
Brief 1 (commit ddf8fd2) handled the Layout-side fix.

Per the architect's per-page bucket table (Decision B in
.convoys/fix-layout-default-user.md), 7 pages needed code changes;
the other 10 of 17 Layout-importing pages already pass `user` correctly.

Pass user={user} to Layout (4 pages, 11 call sites):
  - pages/scanner.js (1 call)
  - pages/decks.js (3 calls)
  - pages/deck-builder.js (4 calls)
  - pages/deck/[id].js (3 calls)
  (All four still import useAuth from lib/auth-context.js — that's
   intentional and stays as-is until the single-auth-provider convoy
   collapses the three parallel auth surfaces.)

Replace leaky page-level seed values with useState(null) + null guards
(2 pages, R2 mitigation):
  - pages/profile.js: useState({email: 'me@...', role: 'user', ...})
                     → useState(null) + ?. on every sync user.* read
                     + early-return guards in getDisplayName/getInitials
                     + conditional render around the "Member since" block
                       so formatDate(undefined) never runs
  - pages/settings.js: same pattern (single user.email reader guarded)

Replace hardcoded const with useAuth from lib/use-auth.js (1 page):
  - pages/card/[id].js: const user = {email: 'me@...'}
                       → const { user } = useAuth() (called unconditionally
                       at the top of the component; rules-of-hooks safe)

Verification:
  - grep 'me@randallstillwell.com' pages/ → 0 hits
  - 21/21 vitest tests pass (16 pre-existing + 5 from Brief 1)
  - npm run lint matches baseline (128 problems pre, 128 post; verified
    via git stash before/after)
  - Manual static read-through of every diff; ReadLints clean on the 7
    files
  - Dev-server smoke: /cards anonymous returned HTTP 200 with 0
    'me@randallstillwell' matches before the user's shared dev server
    became unresponsive mid-session (same dev-server-shared-by-user
    constraint flagged in Brief 1); interactive logged-in smoke is
    parent/operator gated

Flagged-but-deferred (untouched per scope):
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - components/MobileNavigation.js still receives dead user prop
    → cleanup-mobile-nav-dead-props (or fold into god-component-split)

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:22:20 -05:00

461 lines
No EOL
17 KiB
JavaScript
Raw 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/auth-context';
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>
);
}