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>
465 lines
No EOL
17 KiB
JavaScript
465 lines
No EOL
17 KiB
JavaScript
/* eslint-disable @next/next/no-img-element -- External or generated image URLs; next/image migration is out of scope. */
|
|
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 { Button } from '../../components/ui';
|
|
import { useAuth } from '../../lib/use-auth';
|
|
import { getColorIdentity } from '../../lib/mana-symbols';
|
|
|
|
/* 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() {
|
|
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');
|
|
|
|
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 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"
|
|
style={{ borderColor: 'var(--accent-ember)' }}
|
|
/>
|
|
</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"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
Deck not found
|
|
</h1>
|
|
<Link
|
|
href="/decks"
|
|
className="hover:underline"
|
|
style={{ color: 'var(--accent-ember)' }}
|
|
>
|
|
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="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">{getFormatIcon(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">
|
|
{/* Stats Sidebar */}
|
|
<div className="lg:col-span-1">
|
|
<div className="glass-panel rounded-2xl p-6 mb-6">
|
|
<h3
|
|
className="text-lg font-semibold mb-4"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
Statistics
|
|
</h3>
|
|
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between">
|
|
<span style={{ color: 'var(--text-secondary)' }}>Total Cards:</span>
|
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
{stats.totalCards}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span style={{ color: 'var(--text-secondary)' }}>Avg. CMC:</span>
|
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
{stats.avgCmc}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span style={{ color: 'var(--text-secondary)' }}>Format:</span>
|
|
<span className="font-medium" style={{ color: 'var(--text-primary)' }}>
|
|
{deck.format}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Color Distribution */}
|
|
{Object.keys(stats.colorCounts).length > 0 && (
|
|
<div className="mt-6">
|
|
<h4
|
|
className="text-sm font-medium mb-3"
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
>
|
|
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-sm"
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
>
|
|
{color}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className="font-medium"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
{count}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Type Distribution */}
|
|
{Object.keys(stats.typeCounts).length > 0 && (
|
|
<div className="mt-6">
|
|
<h4
|
|
className="text-sm font-medium mb-3"
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
>
|
|
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-sm"
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
>
|
|
{type}
|
|
</span>
|
|
<span
|
|
className="font-medium"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
{count}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Group By Controls */}
|
|
<div className="glass-panel rounded-2xl p-6">
|
|
<h3
|
|
className="text-lg font-semibold mb-4"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
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={`nav-item w-full text-left px-3 py-2 ${
|
|
groupBy === option.value ? 'nav-item-active' : 'nav-item-hover'
|
|
}`}
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Card List */}
|
|
<div className="lg:col-span-3">
|
|
<div className="glass-panel rounded-2xl 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 mb-2"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
Empty Deck
|
|
</h3>
|
|
<p style={{ color: 'var(--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 mb-3 border-b pb-2"
|
|
style={{
|
|
color: 'var(--text-primary)',
|
|
borderColor: 'var(--border)',
|
|
}}
|
|
>
|
|
{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 rounded-xl transition-colors nav-item-hover"
|
|
style={{ backgroundColor: 'var(--bg-primary)' }}
|
|
>
|
|
{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 truncate"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
{card.name}
|
|
</h4>
|
|
<span
|
|
className="font-medium ml-2"
|
|
style={{ color: 'var(--text-primary)' }}
|
|
>
|
|
{card.quantity}x
|
|
</span>
|
|
</div>
|
|
<p
|
|
className="text-sm"
|
|
style={{ color: 'var(--text-secondary)' }}
|
|
>
|
|
{card.set_name}
|
|
</p>
|
|
<div
|
|
className="flex items-center space-x-2 text-xs"
|
|
style={{ color: 'var(--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>
|
|
);
|
|
}
|