deckhearth/pages/deck-builder.js
varutasu ca302a89c1
fix(layout+pages): default user=null + page audit sweep (P0 #7) (#15)
* convoy: scope fix-layout-default-user (P0 #7 — Layout maintainer-email leak)

The last remaining P0 ship-blocker from .convoys/ship-readiness.md.
components/Layout.js line 562 defaults the user prop to a real email
address (me@randallstillwell.com); any page that renders Layout without
passing user explicitly impersonates the maintainer.

Scope: components/Layout.js + audit of 17 pages that import Layout
(grep-confirmed list in convoy file). Single PR likely. Auditor cohort
skipped (no design-system, IA, or browser-smoke surface).

Architect to address:
  - Q1: logged-out rendering branch design (navbar, mobile-nav,
        auth-only items treatment)
  - Q2: page audit triage into always-auth / public-or-auth /
        anonymous-allowed buckets
  - Q3: brief decomposition (single brief / 2 briefs in 1 PR / fan-out)
  - Q4: whether to add vitest coverage for the logged-out branch
        (recommend yes — small surface, high regression protection)

Hard out-of-scope: branding (pick-a-name), auth-provider collapse
(single-auth-provider), Layout god-component split (god-component-split).

depends_on: bump-next-js (shipped), fix-auth-bypass (shipped),
            drop-public-setup (shipped)
addresses: P0 #7 from .convoys/ship-readiness.md
parent: ship-readiness

Co-authored-by: Cursor <cursoragent@cursor.com>

* architect(fix-layout-default-user): plan + briefs 1-2 (Layout fix + page audit)

2 briefs, single PR. ~12 files net (down from the 18 in the original scope —
10 of the 17 Layout-importing pages already pass user explicitly).

Brief 1: components/Layout.js default user=null + Sign-in CTA branch in
  UserProfileDropdown when logged out. Adds first jsdom test in the repo
  at test/components/Layout.test.js (Decision D2) with 5 regression-lock
  assertions. devDeps: jsdom@^29, @testing-library/react@^16.

Brief 2: page audit sweep — 7 pages need code changes:
  - Pass user={user} to Layout: scanner.js, deck-builder.js (×4),
    deck/[id].js (×3), decks.js (×3)
  - Replace page-level useState({email: 'me@...'}) → useState(null) +
    null-guards: profile.js, settings.js
  - Replace hardcoded const user = {email: 'me@...'} with useAuth():
    card/[id].js

Discovered second anti-pattern: profile.js, settings.js, card/[id].js
seed page-level state with the maintainer email. Folded into Brief 2 since
success metric "no real email address remains in any component default-prop"
reads naturally to include page-level seed values.

Decisions:
  A1 — Sign-in CTA replaces avatar+email+dropdown when user===null;
       hides auth-only dropdown (Profile/Settings/Logout/Admin);
       keeps public + community nav visible
  B  — Per-page bucket assignment (10 already correct, 7 need fix);
       full per-page table with justification in convoy file
  C2 — Two briefs in one PR (Brief 1 = Layout + test; Brief 2 = page
       sweep depends on Brief 1). C1 buries the conceptual change under
       mechanical edits; C3 is over-orchestrated for this scope
  D2 — vitest lock-in; first jsdom test in repo; same negative-regression
       style as test/lib/permission-middleware.test.js (synthetic-admin
       shape). devDeps jsdom + @testing-library/react

Risks tracked R1-R8. Biggest: R2 (useState(null) null-deref in 3 leaky
pages — mitigated by audit-pass mandate + manual smoke).

MobileNavigation deliberately NOT folded in: its user prop is dead code
(never reads user.*); different bug class; cleanup queued separately to
avoid scope expansion.

Flagged-but-deferred:
  - 4 pages still import useAuth from lib/auth-context.js
    → single-auth-provider (queued P1 #9)
  - Layout headers still render "Deck Hearth" / "DH" branding
    → pick-a-name (queued P1 #12)
  - MobileNavigation 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)
parent: ship-readiness
Co-authored-by: Cursor <cursoragent@cursor.com>

* feat(layout): default user=null + Sign-in CTA when logged out (Brief 1 of fix-layout-default-user)

Closes the source-side half of P0 #7 from .convoys/ship-readiness.md.
The page-side sweep (Brief 2) follows in a separate commit.

components/Layout.js:
  - Default user prop is now null (was hardcoded to
    { email: 'me@randallstillwell.com', role: 'user' })
  - UserProfileDropdown renders a "Sign in" link to /login when
    user === null instead of the maintainer's email + auth-only menu
    items (Decision A1)
  - All user.* accesses guarded with optional chaining or null checks
  - useState hook stays above the new null-user early return to satisfy
    rules-of-hooks (boot-the-brief caught this on the first try;
    see AGENTS.md Gotcha #11.5)

test/components/Layout.test.js (new):
  - First jsdom test in the repo (Decision D2)
  - 5 regression-lock assertions: no maintainer email ever rendered
    (prop omitted, prop=null), Sign-in link exists with href=/login,
    supplied email renders when prop is set, no "Guest" placeholder
    (locks A1 copy choice)
  - Mocks next/link, next/router (prefetch, replace, events, query),
    and theme-context.useTheme for jsdom safety under Next 16

package.json + package-lock.json:
  - Add jsdom@^29 and @testing-library/react@^16 to devDependencies
  - @testing-library/dom@^10 added explicitly (peer auto-install
    skipped it under npm 11; brief anticipated this fallback)

vitest.config.js (deviation from brief — see PR description):
  - Add esbuild { loader: 'jsx', jsx: 'automatic' } so vitest can
    parse JSX in .js files. Required to import any React component
    written in the repo's Next.js pages-router .js convention
    (AGENTS.md Gotcha #9). The brief said "no change" to this file,
    but JSX-in-.js parsing is a hard prerequisite for the new test
    to import components/Layout.js — the alternatives (rename test
    to .test.jsx; rewrite test in React.createElement) either break
    the test glob or still hit the same Layout.js parse failure.
    Other tests are unaffected (they import non-JSX modules).

Smoke output: see PR description.

addresses: P0 #7 from .convoys/ship-readiness.md (last P0 ship-blocker)
Co-authored-by: Cursor <cursoragent@cursor.com>

* 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>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:31:37 -05:00

823 lines
No EOL
35 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.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import { ManaCost, ColorIdentity, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { useAuth } from '../lib/auth-context';
import { getColorIdentity, getColorSymbol } from '../lib/mana-symbols';
export default function DeckBuilder() {
const { user } = useAuth();
const router = useRouter();
const { deck: deckId } = router.query;
const [deck, setDeck] = useState(null);
const [deckCards, setDeckCards] = useState([]);
const [searchResults, setSearchResults] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(true);
const [searchLoading, setSearchLoading] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [selectedCard, setSelectedCard] = useState(null);
const [showFilters, setShowFilters] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [viewMode, setViewMode] = useState('list'); // 'list' or 'thumbnail'
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
const [filters, setFilters] = useState({
colors: [],
types: [],
cmc: '',
rarity: ''
});
const searchTimeoutRef = useRef(null);
useEffect(() => {
if (user && deckId) {
fetchDeck();
}
}, [user, deckId]);
// Load initial cards when component mounts
useEffect(() => {
if (user) {
searchCards();
}
}, [user]);
useEffect(() => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
searchCards();
}, 300);
return () => {
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
};
}, [searchQuery, filters]);
const fetchDeck = async () => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setDeck(data);
setDeckCards(data.cards || []);
} 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 searchCards = async () => {
setSearchLoading(true);
try {
const token = localStorage.getItem('auth_token');
const params = new URLSearchParams({
game: 'MTG',
limit: '50'
});
// Only add search if there's a query
if (searchQuery.trim()) {
params.append('search', searchQuery);
}
if (filters.colors.length > 0) {
params.append('colors', filters.colors.join(','));
}
if (filters.types.length > 0) {
params.append('types', filters.types.join(','));
}
if (filters.cmc) {
params.append('cmc', filters.cmc);
}
if (filters.rarity) {
params.append('rarity', filters.rarity);
}
const response = await fetch(`/api/cards/search?${params}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const data = await response.json();
setSearchResults(data.cards || []);
}
} catch (error) {
console.error('Error searching cards:', error);
} finally {
setSearchLoading(false);
}
};
const addCardToDeck = async (card, quantity = 1) => {
// Commander format validation
if (deck.format === 'Commander') {
const existingCard = deckCards.find(dc => dc.card_id === card.id);
const currentQuantity = existingCard ? existingCard.quantity : 0;
// Check singleton rule (except basic lands)
if (!isBasicLand(card) && currentQuantity + quantity > 1) {
alert('Commander format allows only 1 copy of each non-basic land card.');
return;
}
// Check total deck size
const totalCards = deckCards.reduce((sum, dc) => sum + dc.quantity, 0);
if (totalCards + quantity > 100) {
alert('Commander decks can have a maximum of 100 cards.');
return;
}
}
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}/cards`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
cardId: card.id,
quantity
})
});
if (response.ok) {
// Refresh deck cards
fetchDeck();
} else {
console.error('Failed to add card to deck');
}
} catch (error) {
console.error('Error adding card to deck:', error);
}
};
const removeCardFromDeck = async (cardId, quantity = 1) => {
try {
const token = localStorage.getItem('auth_token');
const response = await fetch(`/api/decks/${deckId}/cards`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
cardId,
quantity
})
});
if (response.ok) {
// Refresh deck cards
fetchDeck();
} else {
console.error('Failed to remove card from deck');
}
} catch (error) {
console.error('Error removing card from deck:', error);
}
};
const isBasicLand = (card) => {
const basicLands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest'];
return basicLands.includes(card.name);
};
const getDeckStats = () => {
const totalCards = deckCards.reduce((sum, card) => sum + card.quantity, 0);
const avgCmc = deckCards.length > 0
? (deckCards.reduce((sum, card) => sum + (card.cmc || 0) * card.quantity, 0) / totalCards).toFixed(1)
: 0;
const colorCounts = deckCards.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 = deckCards.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 toggleColorFilter = (color) => {
setFilters(prev => ({
...prev,
colors: prev.colors.includes(color)
? prev.colors.filter(c => c !== color)
: [...prev.colors, color]
}));
};
const clearFilters = () => {
setFilters({
colors: [],
types: [],
cmc: '',
rarity: ''
});
};
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 use the deck builder</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>
);
}
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 My Decks
</Link>
</div>
</div>
</Layout>
);
}
const stats = getDeckStats();
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-6">
<div>
<div className="flex items-center space-x-3">
<Link href="/decks" className="text-accent-ember hover:underline">
Back to Decks
</Link>
</div>
<h1 className="text-3xl font-bold text-text-primary mt-2">{deck.name}</h1>
<p className="text-text-secondary">
{deck.format} {stats.totalCards}/100 cards
</p>
</div>
<div className="flex space-x-3">
<button className="bg-bg-secondary text-text-primary px-4 py-2 rounded-lg hover:bg-bg-tertiary transition-colors">
Save Deck
</button>
<Link
href={`/deck/${deck.id}`}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
View Deck
</Link>
</div>
</div>
<div className="flex gap-6 h-[calc(100vh-12rem)]">
{/* Main Deck View - Left Side */}
<div className={`flex-1 transition-all duration-300`}>
<div className="bg-bg-secondary rounded-lg p-6 h-full flex flex-col">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-semibold text-text-primary">
Deck Cards ({stats.totalCards})
</h2>
<button
onClick={() => setSidebarOpen(!sidebarOpen)}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors flex items-center space-x-2"
>
<span>{sidebarOpen ? 'Hide' : 'Show'} Browser</span>
<span>{sidebarOpen ? '→' : '←'}</span>
</button>
</div>
{/* Deck Stats Bar */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6 p-4 bg-bg-primary rounded-lg">
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.totalCards}/100</div>
<div className="text-text-secondary text-sm">Cards</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">{stats.avgCmc}</div>
<div className="text-text-secondary text-sm">Avg CMC</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.colorCounts).length}
</div>
<div className="text-text-secondary text-sm">Colors</div>
</div>
<div className="text-center">
<div className="text-lg font-bold text-text-primary">
{Object.keys(stats.typeCounts).length}
</div>
<div className="text-text-secondary text-sm">Types</div>
</div>
</div>
{/* Deck Cards List */}
<div className="flex-1 overflow-y-auto space-y-2">
{deckCards.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 mb-4">Start building your deck by searching for cards</p>
{!sidebarOpen && (
<button
onClick={() => setSidebarOpen(true)}
className="bg-accent-ember text-white px-4 py-2 rounded-lg hover:bg-accent-ember-dark transition-colors"
>
Open Card Browser
</button>
)}
</div>
) : (
deckCards
.sort((a, b) => a.name.localeCompare(b.name))
.map((card) => (
<div key={`${card.card_id}-${card.id}`} className="flex items-center justify-between p-4 bg-bg-primary rounded-lg hover:bg-bg-tertiary transition-colors">
<div className="flex items-center space-x-4">
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-14 h-20 object-cover rounded shadow-md"
/>
)}
<div>
<h4 className="font-semibold text-text-primary text-lg">{card.name}</h4>
<p className="text-text-secondary text-sm">{card.set_name}</p>
<div className="flex items-center space-x-3 mt-1">
{card.mana_cost && (
<div className="bg-bg-secondary px-2 py-1 rounded">
<ManaCost cost={card.mana_cost} size="sm" useSVG={manaSymbolSettings.useSVG} />
</div>
)}
{card.rarity && (
<span className={`text-xs px-2 py-1 rounded capitalize ${
card.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
card.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
card.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
'bg-green-100 text-green-800'
}`}>
{card.rarity}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center space-x-3">
<span className="text-text-primary font-bold text-lg">{card.quantity}x</span>
<div className="flex items-center space-x-1">
<button
onClick={() => removeCardFromDeck(card.card_id, 1)}
className="w-8 h-8 bg-red-500 text-white rounded-full hover:bg-red-600 transition-colors flex items-center justify-center font-bold"
>
</button>
<button
onClick={() => addCardToDeck(card, 1)}
className="w-8 h-8 bg-accent-ember text-white rounded-full hover:bg-accent-ember-dark transition-colors flex items-center justify-center font-bold"
>
+
</button>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Full Height Right Sidebar - Card Browser */}
<div className={`transition-all duration-300 ${sidebarOpen ? 'w-96' : 'w-0 overflow-hidden'}`}>
<div className="bg-bg-tertiary rounded-lg h-full flex flex-col relative border border-border">
{!selectedCard ? (
<>
{/* Header */}
<div className="flex justify-between items-center p-4 border-b border-border bg-bg-secondary rounded-t-lg">
<h3 className="text-lg font-semibold text-text-primary">Card Browser</h3>
<div className="flex items-center space-x-2">
{/* View Mode Toggle */}
<div className="flex bg-bg-primary rounded-lg p-1">
<button
onClick={() => setViewMode('list')}
className={`px-2 py-1 rounded text-xs transition-colors ${
viewMode === 'list'
? 'bg-accent-ember text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
title="List View"
>
</button>
<button
onClick={() => setViewMode('thumbnail')}
className={`px-2 py-1 rounded text-xs transition-colors ${
viewMode === 'thumbnail'
? 'bg-accent-ember text-white'
: 'text-text-secondary hover:text-text-primary'
}`}
title="Thumbnail View"
>
</button>
</div>
<button
onClick={() => setShowSettings(!showSettings)}
className={`text-text-secondary hover:text-text-primary transition-colors p-1 ${
showSettings ? 'text-accent-ember' : ''
}`}
title="Settings"
>
</button>
<button
onClick={() => setSidebarOpen(false)}
className="text-text-secondary hover:text-text-primary transition-colors p-1"
title="Collapse"
>
</button>
</div>
</div>
{/* Search */}
<div className="p-4 border-b border-border">
<div className="flex space-x-2">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search for cards..."
className="flex-1 px-3 py-2 border border-border rounded-lg focus:outline-none focus:ring-2 focus:ring-accent-ember bg-bg-primary text-text-primary text-sm"
/>
<button
onClick={() => setShowFilters(!showFilters)}
className={`p-2 rounded-lg transition-colors ${
showFilters || filters.colors.length > 0 || filters.cmc || filters.rarity
? 'bg-accent-ember text-white'
: 'bg-bg-primary text-text-secondary hover:bg-bg-tertiary'
}`}
title="Filters"
>
🔍
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div className="p-4 border-b border-border bg-bg-primary">
<ManaSymbolSettings onSettingsChange={setManaSymbolSettings} />
</div>
)}
{/* Quick Filters */}
{showFilters && (
<div className="p-4 border-b border-border bg-bg-primary space-y-3">
{/* Color Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-text-secondary text-xs font-medium">Colors</label>
{(filters.colors.length > 0 || filters.cmc || filters.rarity) && (
<button
onClick={clearFilters}
className="text-xs text-accent-ember hover:underline"
>
Clear All
</button>
)}
</div>
<div className="flex space-x-1">
{['W', 'U', 'B', 'R', 'G'].map(color => (
<ColorFilterSymbol
key={color}
color={color}
isActive={filters.colors.includes(color)}
onClick={toggleColorFilter}
size="sm"
useSVG={manaSymbolSettings.useSVG}
/>
))}
</div>
</div>
{/* CMC and Rarity */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">CMC</label>
<select
value={filters.cmc}
onChange={(e) => setFilters({...filters, cmc: e.target.value})}
className="w-full px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="">Any</option>
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6+">6+</option>
</select>
</div>
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Rarity</label>
<select
value={filters.rarity}
onChange={(e) => setFilters({...filters, rarity: e.target.value})}
className="w-full px-2 py-1 border border-border rounded text-xs focus:outline-none focus:ring-1 focus:ring-accent-ember bg-bg-secondary text-text-primary"
>
<option value="">Any</option>
<option value="common">Common</option>
<option value="uncommon">Uncommon</option>
<option value="rare">Rare</option>
<option value="mythic">Mythic</option>
</select>
</div>
</div>
</div>
)}
{/* Card List */}
<div className="flex-1 overflow-y-auto p-4">
{viewMode === 'list' ? (
/* List View */
<div className="space-y-1">
{searchLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-ember mx-auto"></div>
<p className="text-text-secondary mt-2 text-xs">Searching...</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8">
<div className="text-2xl mb-2">🔍</div>
<p className="text-text-secondary text-xs">
{searchQuery ? 'No cards found' : 'No cards available'}
</p>
</div>
) : (
searchResults.map((card) => (
<div
key={card.id}
className="flex items-center p-2 bg-bg-secondary rounded hover:bg-bg-primary transition-colors cursor-pointer"
onClick={() => setSelectedCard(card)}
>
<div className="flex items-center space-x-2 flex-1 min-w-0">
{card.image_url && (
<img
src={card.image_url}
alt={card.name}
className="w-8 h-11 object-cover rounded flex-shrink-0"
/>
)}
<div className="min-w-0 flex-1">
<h4 className="font-medium text-text-primary text-xs truncate">{card.name}</h4>
<p className="text-text-secondary text-xs truncate">{card.set_name}</p>
<div className="flex items-center space-x-1">
{card.mana_cost && (
<ManaCost cost={card.mana_cost} size="xs" useSVG={manaSymbolSettings.useSVG} />
)}
{card.rarity && (
<span className={`text-xs px-1 rounded capitalize ${
card.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
card.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
card.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
'bg-green-100 text-green-800'
}`}>
{card.rarity[0].toUpperCase()}
</span>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
) : (
/* Thumbnail View */
<div>
{searchLoading ? (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-accent-ember mx-auto"></div>
<p className="text-text-secondary mt-2 text-xs">Searching...</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8">
<div className="text-2xl mb-2">🔍</div>
<p className="text-text-secondary text-xs">
{searchQuery ? 'No cards found' : 'No cards available'}
</p>
</div>
) : (
<div className="grid grid-cols-3 gap-2">
{searchResults.map((card) => (
<div
key={card.id}
className="relative group cursor-pointer"
onClick={() => setSelectedCard(card)}
>
{card.image_url ? (
<div className="relative">
<img
src={card.image_url}
alt={card.name}
className="w-full aspect-[2.5/3.5] object-cover rounded-lg shadow-sm group-hover:shadow-md transition-shadow"
/>
{/* Hover overlay with card name */}
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-60 transition-all duration-200 rounded-lg flex items-end">
<div className="p-2 text-white opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<p className="text-xs font-medium truncate">{card.name}</p>
<p className="text-xs opacity-75 truncate">{card.set_name}</p>
</div>
</div>
{/* Rarity indicator */}
{card.rarity && (
<div className={`absolute top-1 right-1 w-2 h-2 rounded-full ${
card.rarity === 'mythic' ? 'bg-orange-500' :
card.rarity === 'rare' ? 'bg-yellow-500' :
card.rarity === 'uncommon' ? 'bg-gray-400' :
'bg-green-500'
}`}></div>
)}
</div>
) : (
<div className="w-full aspect-[2.5/3.5] bg-bg-secondary rounded-lg flex items-center justify-center group-hover:bg-bg-primary transition-colors">
<div className="text-center p-2">
<p className="text-xs font-medium text-text-primary truncate">{card.name}</p>
<p className="text-xs text-text-secondary truncate">{card.set_name}</p>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</>
) : (
/* Card Detail View */
<>
{/* Detail Header */}
<div className="flex items-center p-4 border-b border-border bg-bg-secondary rounded-t-lg">
<button
onClick={() => setSelectedCard(null)}
className="text-text-secondary hover:text-text-primary transition-colors mr-3"
>
Back
</button>
<h3 className="text-lg font-semibold text-text-primary truncate">{selectedCard.name}</h3>
</div>
{/* Card Detail Content */}
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{/* Card Image */}
{selectedCard.image_url && (
<div className="text-center">
<img
src={selectedCard.image_url}
alt={selectedCard.name}
className="w-full max-w-64 mx-auto rounded-lg shadow-lg"
/>
</div>
)}
{/* Card Info */}
<div className="space-y-3">
<div>
<h4 className="font-semibold text-text-primary text-lg">{selectedCard.name}</h4>
<p className="text-text-secondary text-sm">{selectedCard.set_name}</p>
</div>
{selectedCard.mana_cost && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Mana Cost</label>
<div className="bg-bg-primary px-3 py-2 rounded">
<ManaCost cost={selectedCard.mana_cost} size="md" useSVG={manaSymbolSettings.useSVG} />
</div>
</div>
)}
{selectedCard.card_type && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Type</label>
<div className="bg-bg-primary px-3 py-2 rounded text-sm">{selectedCard.card_type}</div>
</div>
)}
{selectedCard.oracle_text && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Oracle Text</label>
<div className="bg-bg-primary px-3 py-2 rounded text-sm whitespace-pre-wrap">{selectedCard.oracle_text}</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{selectedCard.rarity && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">Rarity</label>
<div className={`px-3 py-2 rounded text-sm capitalize ${
selectedCard.rarity === 'mythic' ? 'bg-orange-100 text-orange-800' :
selectedCard.rarity === 'rare' ? 'bg-yellow-100 text-yellow-800' :
selectedCard.rarity === 'uncommon' ? 'bg-gray-100 text-gray-800' :
'bg-green-100 text-green-800'
}`}>
{selectedCard.rarity}
</div>
</div>
)}
{selectedCard.cmc !== undefined && (
<div>
<label className="block text-text-secondary text-xs font-medium mb-1">CMC</label>
<div className="bg-bg-primary px-3 py-2 rounded text-sm">{selectedCard.cmc}</div>
</div>
)}
</div>
</div>
</div>
</div>
</>
)}
{/* Floating Action Button */}
{selectedCard && (
<div className="absolute bottom-4 left-4 right-4">
<button
onClick={() => {
addCardToDeck(selectedCard);
setSelectedCard(null);
}}
className="w-full bg-accent-ember text-white py-3 rounded-lg hover:bg-accent-ember-dark transition-colors font-semibold shadow-lg"
>
Add to Deck
</button>
</div>
)}
</div>
</div>
</div>
</div>
</Layout>
);
}