deckhearth/components/DeckBuilderCardBrowser.js
Randall Stillwell 1769d4576a refactor(design): site-wide sweep — broken Tailwind tokens, rounded corners, SearchBar primitive (#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 12:55:29 -05:00

650 lines
24 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.

/* eslint-disable @next/next/no-img-element -- External card image URLs; next/image migration is out of scope. */
import { ManaCost, ColorFilterSymbol } from './ManaSymbols';
import ManaSymbolSettings from './ManaSymbolSettings';
import { SearchBar } from './ui';
/* 2026-06-04 design-sweep pass: this file used broken Tailwind token
classes (bg-bg-*, text-text-*, border-border, bg-accent-ember,
focus:ring-accent-ember) throughout — none of those are defined in
tailwind.config.js, so the browser pane rendered as transparent
nothingness. Sweep replaces them with inline style={{ ... CSS
vars ... }}, the SearchBar primitive for the card search input,
and `glass-panel` + `rounded-2xl` on the outer pane to match the
Liquid Glass system. Rarity badge colours were converted from the
stale orange/yellow/gray/green Tailwind defaults to a neutral
inline pair (bg-tertiary surface + ember/text-primary fg) so they
render correctly in dark theme. */
const rarityBadgeStyle = (rarity) => {
switch (rarity) {
case 'mythic':
return {
backgroundColor: 'var(--accent-ember)',
color: '#ffffff',
};
case 'rare':
return {
backgroundColor: 'var(--accent-flame)',
color: '#ffffff',
};
case 'uncommon':
return {
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-primary)',
};
default: // common
return {
backgroundColor: 'var(--bg-tertiary)',
color: 'var(--text-secondary)',
};
}
};
const rarityDotColor = (rarity) => {
switch (rarity) {
case 'mythic': return 'var(--accent-ember)';
case 'rare': return 'var(--accent-flame)';
case 'uncommon': return 'var(--text-secondary)';
default: return 'var(--accent-wood)';
}
};
export default function DeckBuilderCardBrowser({
selectedCard,
onSelectCard,
onClearSelectedCard,
viewMode,
onViewModeChange,
showSettings,
onToggleSettings,
onCollapseSidebar,
searchQuery,
onSearchQueryChange,
showFilters,
onToggleFilters,
filters,
onFiltersChange,
onClearFilters,
onToggleColorFilter,
searchLoading,
searchResults,
manaSymbolSettings,
onManaSymbolSettingsChange,
onAddCardToDeck,
}) {
const hasActiveFilters =
filters.colors.length > 0 || filters.cmc || filters.rarity;
return (
<div
className="glass-panel rounded-2xl h-full flex flex-col relative"
style={{ borderColor: 'var(--border)' }}
>
{!selectedCard ? (
<>
{/* Header */}
<div
className="flex justify-between items-center p-4 border-b rounded-t-2xl"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--bg-secondary)',
}}
>
<h3
className="text-lg font-semibold"
style={{ color: 'var(--text-primary)' }}
>
Card Browser
</h3>
<div className="flex items-center space-x-2">
{/* View Mode Toggle */}
<div
className="flex rounded-xl p-1"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<button
onClick={() => onViewModeChange('list')}
className="px-2 py-1 rounded-lg text-xs transition-colors"
style={{
backgroundColor:
viewMode === 'list' ? 'var(--accent-ember)' : 'transparent',
color:
viewMode === 'list' ? '#ffffff' : 'var(--text-secondary)',
}}
title="List View"
>
</button>
<button
onClick={() => onViewModeChange('thumbnail')}
className="px-2 py-1 rounded-lg text-xs transition-colors"
style={{
backgroundColor:
viewMode === 'thumbnail'
? 'var(--accent-ember)'
: 'transparent',
color:
viewMode === 'thumbnail'
? '#ffffff'
: 'var(--text-secondary)',
}}
title="Thumbnail View"
>
</button>
</div>
<button
onClick={() => onToggleSettings()}
className="transition-colors p-1"
style={{
color: showSettings
? 'var(--accent-ember)'
: 'var(--text-secondary)',
}}
title="Settings"
aria-label="Settings"
>
</button>
<button
onClick={() => onCollapseSidebar()}
className="transition-colors p-1"
style={{ color: 'var(--text-secondary)' }}
title="Collapse"
aria-label="Collapse browser"
>
</button>
</div>
</div>
{/* Search */}
<div
className="p-4 border-b"
style={{ borderColor: 'var(--border)' }}
>
<div className="flex space-x-2">
<div className="flex-1">
<SearchBar
value={searchQuery}
onChange={(e) => onSearchQueryChange(e.target.value)}
onClear={() => onSearchQueryChange('')}
placeholder="Search for cards…"
/>
</div>
<button
onClick={() => onToggleFilters()}
className="p-2 rounded-xl transition-colors"
style={{
backgroundColor:
showFilters || hasActiveFilters
? 'var(--accent-ember)'
: 'var(--bg-primary)',
color:
showFilters || hasActiveFilters
? '#ffffff'
: 'var(--text-secondary)',
}}
title="Filters"
aria-label="Toggle filters"
>
🔍
</button>
</div>
</div>
{/* Settings Panel */}
{showSettings && (
<div
className="p-4 border-b"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--bg-primary)',
}}
>
<ManaSymbolSettings onSettingsChange={onManaSymbolSettingsChange} />
</div>
)}
{/* Quick Filters */}
{showFilters && (
<div
className="p-4 border-b space-y-3"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--bg-primary)',
}}
>
{/* Color Filters */}
<div>
<div className="flex items-center justify-between mb-2">
<label
className="text-xs font-medium"
style={{ color: 'var(--text-secondary)' }}
>
Colors
</label>
{hasActiveFilters && (
<button
onClick={onClearFilters}
className="text-xs hover:underline"
style={{ color: 'var(--accent-ember)' }}
>
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={onToggleColorFilter}
size="sm"
useSVG={manaSymbolSettings.useSVG}
/>
))}
</div>
</div>
{/* CMC and Rarity */}
<div className="grid grid-cols-2 gap-2">
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
htmlFor="filter-cmc"
>
CMC
</label>
<select
id="filter-cmc"
value={filters.cmc}
onChange={(e) =>
onFiltersChange({ ...filters, cmc: e.target.value })
}
className="input-field w-full text-xs py-1"
>
<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-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
htmlFor="filter-rarity"
>
Rarity
</label>
<select
id="filter-rarity"
value={filters.rarity}
onChange={(e) =>
onFiltersChange({ ...filters, rarity: e.target.value })
}
className="input-field w-full text-xs py-1"
>
<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' ? (
<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 mx-auto"
style={{ borderColor: 'var(--accent-ember)' }}
/>
<p
className="mt-2 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
Searching...
</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8">
<div className="text-2xl mb-2">🔍</div>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{searchQuery ? 'No cards found' : 'No cards available'}
</p>
</div>
) : (
searchResults.map((card) => (
<div
key={card.id}
className="flex items-center p-2 rounded-xl transition-colors cursor-pointer nav-item-hover"
style={{ backgroundColor: 'var(--bg-secondary)' }}
onClick={() => onSelectCard(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-xs truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</h4>
<p
className="text-xs truncate"
style={{ color: 'var(--text-secondary)' }}
>
{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"
style={rarityBadgeStyle(card.rarity)}
>
{card.rarity[0].toUpperCase()}
</span>
)}
</div>
</div>
</div>
</div>
))
)}
</div>
) : (
<div>
{searchLoading ? (
<div className="text-center py-8">
<div
className="animate-spin rounded-full h-6 w-6 border-b-2 mx-auto"
style={{ borderColor: 'var(--accent-ember)' }}
/>
<p
className="mt-2 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
Searching...
</p>
</div>
) : searchResults.length === 0 ? (
<div className="text-center py-8">
<div className="text-2xl mb-2">🔍</div>
<p
className="text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{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={() => onSelectCard(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-xl shadow-sm group-hover:shadow-md transition-shadow"
/>
<div className="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-60 transition-all duration-200 rounded-xl 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>
{card.rarity && (
<div
className="absolute top-1 right-1 w-2 h-2 rounded-full"
style={{ backgroundColor: rarityDotColor(card.rarity) }}
/>
)}
</div>
) : (
<div
className="w-full aspect-[2.5/3.5] rounded-xl flex items-center justify-center transition-colors"
style={{ backgroundColor: 'var(--bg-secondary)' }}
>
<div className="text-center p-2">
<p
className="text-xs font-medium truncate"
style={{ color: 'var(--text-primary)' }}
>
{card.name}
</p>
<p
className="text-xs truncate"
style={{ color: 'var(--text-secondary)' }}
>
{card.set_name}
</p>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
)}
</div>
</>
) : (
/* Card Detail View */
<>
<div
className="flex items-center p-4 border-b rounded-t-2xl"
style={{
borderColor: 'var(--border)',
backgroundColor: 'var(--bg-secondary)',
}}
>
<button
onClick={() => onClearSelectedCard()}
className="transition-colors mr-3"
style={{ color: 'var(--text-secondary)' }}
>
Back
</button>
<h3
className="text-lg font-semibold truncate"
style={{ color: 'var(--text-primary)' }}
>
{selectedCard.name}
</h3>
</div>
<div className="flex-1 overflow-y-auto p-4">
<div className="space-y-4">
{selectedCard.image_url && (
<div className="text-center">
<img
src={selectedCard.image_url}
alt={selectedCard.name}
className="w-full max-w-64 mx-auto rounded-xl shadow-lg"
/>
</div>
)}
<div className="space-y-3">
<div>
<h4
className="font-semibold text-lg"
style={{ color: 'var(--text-primary)' }}
>
{selectedCard.name}
</h4>
<p
className="text-sm"
style={{ color: 'var(--text-secondary)' }}
>
{selectedCard.set_name}
</p>
</div>
{selectedCard.mana_cost && (
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Mana Cost
</label>
<div
className="px-3 py-2 rounded-xl"
style={{ backgroundColor: 'var(--bg-primary)' }}
>
<ManaCost
cost={selectedCard.mana_cost}
size="md"
useSVG={manaSymbolSettings.useSVG}
/>
</div>
</div>
)}
{selectedCard.card_type && (
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Type
</label>
<div
className="px-3 py-2 rounded-xl text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
}}
>
{selectedCard.card_type}
</div>
</div>
)}
{selectedCard.oracle_text && (
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Oracle Text
</label>
<div
className="px-3 py-2 rounded-xl text-sm whitespace-pre-wrap"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
}}
>
{selectedCard.oracle_text}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
{selectedCard.rarity && (
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
>
Rarity
</label>
<div
className="px-3 py-2 rounded-xl text-sm capitalize"
style={rarityBadgeStyle(selectedCard.rarity)}
>
{selectedCard.rarity}
</div>
</div>
)}
{selectedCard.cmc !== undefined && (
<div>
<label
className="block text-xs font-medium mb-1"
style={{ color: 'var(--text-secondary)' }}
>
CMC
</label>
<div
className="px-3 py-2 rounded-xl text-sm"
style={{
backgroundColor: 'var(--bg-primary)',
color: 'var(--text-primary)',
}}
>
{selectedCard.cmc}
</div>
</div>
)}
</div>
</div>
</div>
</div>
</>
)}
{/* Floating Action Button */}
{selectedCard && (
<div className="absolute bottom-4 left-4 right-4">
<button
onClick={() => onAddCardToDeck(selectedCard)}
className="w-full py-3 rounded-xl transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98] font-semibold shadow-lg"
style={{
background:
'linear-gradient(135deg, var(--accent-ember) 0%, var(--accent-flame) 100%)',
color: '#ffffff',
boxShadow: 'var(--rim-light-inner), var(--ember-rim-pronounced)',
}}
>
Add to Deck
</button>
</div>
)}
</div>
);
}