deckhearth/pages/deck-builder.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

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/use-auth';
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>
);
}