deckhearth/pages/decks.js
varutasu 22364de8c9
refactor(design): site-wide sweep — broken Tailwind tokens, rounded corners, SearchBar primitive (#117) (#117)
Comprehensive design sweep across the rest of the app following the
shipped Liquid Glass + corner-border-light system (#116).

## Three classes of finding

### 1. Broken Tailwind token classes (HIGH — pages were unstyled)

The decks / deck-builder / deck-detail cluster relied on Tailwind
classes that don't exist in `tailwind.config.js` (no `bg-bg-*`,
`text-text-*`, `border-border`, `bg-accent-ember`,
`focus:ring-accent-ember`, `hover:bg-accent-ember-dark`). Those classes
produced ZERO CSS — backgrounds were transparent, borders invisible,
hover states absent.

Rewrote with inline `style={{ ... CSS vars ... }}` + the `<Button>` /
`<SearchBar>` primitives + `glass-panel` surfaces:

- `pages/decks.js` (full page)
- `pages/deck/[id].js` (header, stats sidebar, group-by controls,
  card list)
- `pages/deck-builder.js` (loading spinner)
- `components/DeckBuilderView.js` (toolbar + main panel)
- `components/DeckBuilderCardBrowser.js` (full rewrite; integrated
  `<SearchBar>` for the card-picker input)
- `components/DeckBuilderDeckList.js` (full rewrite)
- `components/DeckBuilderStatsBar.js`
- `components/ManaSymbolSettings.js`
- `components/ManaSymbols.js` (single `text-text-secondary`)
- `pages/admin/card-editor.js` cluster was already clean

### 2. Duplicative / stale page searches

Replaced raw `<input>` search controls with the `<SearchBar>` primitive
(adds clear button, ember focus ring, system-consistent rounded
corners). Kept page-specific filter searches (they filter the visible
list — distinct from the global TopSearchBar command palette):

- `pages/my-cards.js`
- `pages/community/collections.js`
- `components/CardsPageView.js`
- `components/CollectionPageView.js`
- `components/DeckBuilderCardBrowser.js`

`pages/my-cards.js` filter wrapper also lifted into a `glass-panel`
chip instead of a solid `var(--bg-primary)` band.

### 3. Square corners + stale palette in shared views

- `components/CollectionPageView.js`: 10 action buttons (`rounded-lg`
  + `hover:bg-gray-50`) → `rounded-xl` + `nav-item-hover`; 4 filter
  selects (`focus:ring-purple-500 rounded-lg`) → `.input-field`;
  view-mode toggle (`bg-white text-gray-900` — invisible in dark mode)
  → tokenised; SYSTEM badge gradient (`from-blue-500 to-purple-600`)
  → ember↔flame; tooltip (`bg-gray-900`) → `glass-panel-strong`;
  search-results dropdown (`bg-white border-gray-200` — invisible in
  dark mode) → `glass-panel-strong`; Activity / game-count /
  TCG-game badges palette-aligned.
- `components/CardsPageView.js`: "Load More Cards" button
  (`bg-gradient-to-r from-blue-500 to-purple-600 rounded-lg`) →
  `<Button variant="primary" size="lg">`.
- `components/CollectionsPageView.js`: matching SYSTEM badge +
  tooltip cleanup.
- `components/ShareModal.js`: user-search dropdown
  (`border-gray-200 hover:bg-gray-50`) and email-invite card moved
  onto `glass-panel` + `nav-item-hover`; social-share buttons
  `rounded-lg hover:bg-gray-50` → `rounded-xl nav-item-hover`.
- `components/Layout.js`: profile-menu dropdown row
  (`hover:bg-gray-50 dark:hover:bg-gray-700`) → `nav-item-hover`.
- `components/CardItem.js`: bulk-select checkbox
  `focus:ring-purple-500` → ember.

### 4. `dark:` modifier classes (broken with `[data-theme]` theming)

This app uses `[data-theme="dark"]` CSS selector theming, not
Tailwind's `class` strategy, so `dark:bg-green-900/20` etc. produced
no CSS in dark mode. Affected alerts on `pages/settings.js` and
`pages/profile.js` — replaced with `glass-panel` + semantic border
colour (flame for success, #dc2626 for error).

`pages/settings.js` sidebar nav also moved off its hardcoded full-ember
fill onto the system `nav-item` / `nav-item-active` / `nav-item-hover`
pattern for consistency with the global sidebar.

## Verification

- `npm run build` — green (Next 16 + Turbopack)
- `npm run lint` — 0 errors, 1 unrelated pre-existing warning
- `npm run test:run` — 113/113 pass (no test changes needed)

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 14:06:22 -05:00

492 lines
18 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 { Modal, Input, Button } from '../components/ui';
import { useAuth } from '../lib/use-auth';
/* 2026-06-04 design-sweep pass: the Tailwind token classes that this
page relied on (bg-bg-secondary, text-text-primary, border-border,
bg-accent-ember, hover:bg-accent-ember-dark, focus:ring-accent-ember)
are NOT defined in tailwind.config.js — they produced zero CSS,
leaving the page visually unstyled (transparent backgrounds, no
borders, no hover states). Sweep replaces those with inline
style={{ ... CSS vars ... }} and the Button / SearchBar primitives
so the page actually renders with the Liquid Glass + corner-border
design system. `rounded-lg` → `rounded-xl` across the board to match
the system standard. */
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,
});
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);
}
};
useEffect(() => {
if (user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- load decks when user is available
fetchDecks();
}
}, [user]);
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 });
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 '🃏';
}
};
// Single neutral chip style for format badges — the prior
// getFormatColor() returned 5 stale Tailwind colour pairs
// (bg-purple-100, bg-blue-100, etc.) that don't fit the
// Deck Hearth palette and don't render in dark theme anyway.
const formatBadgeStyle = {
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
};
// Reusable inline-style for the .card surfaces used as stat
// cards + deck cards. We can't use the .glass-panel class +
// rounded-xl directly because we want the same gradient-border
// catch-light effect that the chrome chips have, and inline
// styles for that pattern are verbose. Easiest path: opt into
// the existing .glass-panel class for the multi-layer bg, then
// override border-radius via className. (.glass-panel doesn't
// set border-radius so we control it via Tailwind.)
const chipBgStyle = {};
// textarea / select use .input-field which is the Deck-Hearth
// standard input class (rounded-2xl + ember focus ring).
const inputFieldClass = 'input-field w-full';
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" style={{ color: 'var(--text-primary)' }}>
Please log in to view your decks
</h1>
<Link href="/login" style={{ color: 'var(--accent-ember)' }} className="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"
style={{ borderColor: 'var(--accent-ember)' }}
/>
</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" style={{ color: 'var(--text-primary)' }}>
My Decks
</h1>
<p className="mt-2" style={{ color: 'var(--text-secondary)' }}>
Build and manage your MTG decks
</p>
</div>
<Button variant="primary" onClick={() => setShowCreateModal(true)}>
Create New Deck
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8">
<div className="glass-panel rounded-2xl p-6" style={chipBgStyle}>
<div className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{decks.length}
</div>
<div style={{ color: 'var(--text-secondary)' }}>Total Decks</div>
</div>
<div className="glass-panel rounded-2xl p-6">
<div className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{decks.filter((d) => d.format === 'Commander').length}
</div>
<div style={{ color: 'var(--text-secondary)' }}>Commander</div>
</div>
<div className="glass-panel rounded-2xl p-6">
<div className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{decks.filter((d) => d.is_public).length}
</div>
<div style={{ color: 'var(--text-secondary)' }}>Public</div>
</div>
<div className="glass-panel rounded-2xl p-6">
<div className="text-2xl font-bold" style={{ color: 'var(--text-primary)' }}>
{decks.reduce((sum, deck) => sum + (deck.card_count || 0), 0)}
</div>
<div style={{ color: 'var(--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 mb-2" style={{ color: 'var(--text-primary)' }}>
No decks yet
</h3>
<p className="mb-6" style={{ color: 'var(--text-secondary)' }}>
Create your first deck to get started
</p>
<Button variant="primary" onClick={() => setShowCreateModal(true)}>
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="glass-panel rounded-2xl p-6 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"
style={formatBadgeStyle}
>
{deck.format}
</span>
</div>
<div className="flex space-x-2">
<button
onClick={() => setEditingDeck({ ...deck })}
className="transition-colors"
style={{ color: 'var(--text-secondary)' }}
aria-label="Edit deck"
>
</button>
<button
onClick={() => handleDeleteDeck(deck.id)}
className="transition-colors"
style={{ color: 'var(--text-secondary)' }}
aria-label="Delete deck"
>
🗑
</button>
</div>
</div>
<h3 className="text-xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{deck.name}
</h3>
{deck.description && (
<p
className="text-sm mb-4 line-clamp-2"
style={{ color: 'var(--text-secondary)' }}
>
{deck.description}
</p>
)}
<div
className="flex justify-between items-center text-sm mb-4"
style={{ color: 'var(--text-secondary)' }}
>
<span>{deck.card_count || 0} cards</span>
{deck.is_public && (
<span style={{ color: 'var(--accent-ember)' }}>Public</span>
)}
</div>
<div className="flex space-x-2">
<Link href={`/deck-builder?deck=${deck.id}`} className="flex-1">
<Button variant="primary" size="md" className="w-full">
Edit Deck
</Button>
</Link>
<Link href={`/deck/${deck.id}`} className="flex-1">
<Button variant="secondary" size="md" className="w-full">
View
</Button>
</Link>
</div>
</div>
))}
</div>
)}
<Modal
open={showCreateModal}
onClose={() => setShowCreateModal(false)}
title="Create New Deck"
size="md"
>
<form onSubmit={handleCreateDeck} className="space-y-4">
<Input
label="Deck Name *"
required
value={newDeck.name}
onChange={(e) => setNewDeck({ ...newDeck, name: e.target.value })}
placeholder="Enter deck name"
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="create-deck-format"
>
Format
</label>
<select
id="create-deck-format"
value={newDeck.format}
onChange={(e) => setNewDeck({ ...newDeck, format: e.target.value })}
className={inputFieldClass}
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="create-deck-description"
>
Description
</label>
<textarea
id="create-deck-description"
value={newDeck.description}
onChange={(e) => setNewDeck({ ...newDeck, description: e.target.value })}
className={inputFieldClass}
rows="3"
placeholder="Describe your deck strategy..."
/>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={newDeck.is_public}
onChange={(e) => setNewDeck({ ...newDeck, is_public: e.target.checked })}
className="mr-2"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Make deck public
</span>
</label>
<div className="flex space-x-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={() => setShowCreateModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" variant="primary" className="flex-1">
Create Deck
</Button>
</div>
</form>
</Modal>
<Modal
open={!!editingDeck}
onClose={() => setEditingDeck(null)}
title="Edit Deck"
size="md"
>
{editingDeck && (
<form onSubmit={handleEditDeck} className="space-y-4">
<Input
label="Deck Name *"
required
value={editingDeck.name}
onChange={(e) => setEditingDeck({ ...editingDeck, name: e.target.value })}
/>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="edit-deck-format"
>
Format
</label>
<select
id="edit-deck-format"
value={editingDeck.format}
onChange={(e) => setEditingDeck({ ...editingDeck, format: e.target.value })}
className={inputFieldClass}
>
<option value="Commander">Commander</option>
<option value="Standard">Standard</option>
<option value="Modern">Modern</option>
<option value="Legacy">Legacy</option>
</select>
</div>
<div>
<label
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
htmlFor="edit-deck-description"
>
Description
</label>
<textarea
id="edit-deck-description"
value={editingDeck.description || ''}
onChange={(e) => setEditingDeck({ ...editingDeck, description: e.target.value })}
className={inputFieldClass}
rows="3"
/>
</div>
<label className="flex items-center">
<input
type="checkbox"
checked={editingDeck.is_public}
onChange={(e) => setEditingDeck({ ...editingDeck, is_public: e.target.checked })}
className="mr-2"
style={{ accentColor: 'var(--accent-ember)' }}
/>
<span className="text-sm" style={{ color: 'var(--text-secondary)' }}>
Make deck public
</span>
</label>
<div className="flex space-x-3 pt-2">
<Button
type="button"
variant="secondary"
onClick={() => setEditingDeck(null)}
className="flex-1"
>
Cancel
</Button>
<Button type="submit" variant="primary" className="flex-1">
Save Changes
</Button>
</div>
</form>
)}
</Modal>
</div>
</Layout>
);
}