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

## Three classes of finding

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

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

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

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

### 2. Duplicative / stale page searches

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

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

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

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

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

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

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

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

## Verification

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

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

450 lines
No EOL
15 KiB
JavaScript

import { useState, useEffect, useRef } from 'react';
import { useRouter } from 'next/router';
import Link from 'next/link';
import Layout from '../components/Layout';
import CardItem from '../components/CardItem';
import BulkSelectionToolbar from '../components/BulkSelectionToolbar';
import CollectionSelectionModal from '../components/CollectionSelectionModal';
import { ManaCost, ColorFilterSymbol } from '../components/ManaSymbols';
import ManaSymbolSettings from '../components/ManaSymbolSettings';
import { Button, SearchBar } from '../components/ui';
import { useAuth } from '../lib/use-auth';
import { VOCAB } from '../lib/collection-vocabulary.js';
export default function MyCards() {
const router = useRouter();
const { user, loading: authLoading } = useAuth();
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [selectedTCG, setSelectedTCG] = useState('all');
const [selectedRarity, setSelectedRarity] = useState('all');
const [selectedSet, setSelectedSet] = useState('all');
const [selectedValueRange, setSelectedValueRange] = useState('all');
const [viewMode, setViewMode] = useState('grid'); // grid or list
const [pagination, setPagination] = useState({
page: 1,
limit: 50,
total: 0,
pages: 0
});
const [filters, setFilters] = useState({
games: [],
rarities: [],
sets: []
});
const [hasMore, setHasMore] = useState(true);
const loadingMoreRef = useRef(false);
const hasMoreRef = useRef(true);
// Bulk selection state
const [selectedCards, setSelectedCards] = useState([]);
const [favoritedCards, setFavoritedCards] = useState(new Set());
// Modal states
const [showCollectionModal, setShowCollectionModal] = useState(false);
const [cardsToAdd, setCardsToAdd] = useState([]);
// Mana symbol settings
const [manaSymbolSettings, setManaSymbolSettings] = useState({ useSVG: false });
// Redirect to login if not authenticated
useEffect(() => {
if (!authLoading && !user) {
router.push('/login');
}
}, [authLoading, user, router]);
// Fetch owned cards from database
const fetchCards = async (isLoadMore = false) => {
try {
if (isLoadMore) {
setLoadingMore(true);
loadingMoreRef.current = true;
} else {
setLoading(true);
setPagination(prev => ({ ...prev, page: 1 }));
}
const currentPage = isLoadMore ? pagination.page + 1 : 1;
// Build query parameters for owned cards only
const params = new URLSearchParams({
page: currentPage.toString(),
limit: pagination.limit.toString(),
...(searchQuery && { search: searchQuery }),
...(selectedTCG !== 'all' && { tcg: selectedTCG }),
...(selectedRarity !== 'all' && { rarity: selectedRarity }),
...(selectedSet !== 'all' && { set: selectedSet }),
...(selectedValueRange !== 'all' && { valueRange: selectedValueRange })
});
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`/api/cards/owned?${params}`, { headers });
if (response.ok) {
const data = await response.json();
if (isLoadMore) {
setCards(prev => [...prev, ...data.cards]);
setPagination(prev => ({ ...prev, page: currentPage }));
} else {
setCards(data.cards);
setPagination(data.pagination);
}
// Update hasMore state
const newHasMore = currentPage < data.pagination.pages;
setHasMore(newHasMore);
hasMoreRef.current = newHasMore;
// Load filter options on first load
if (!isLoadMore && data.filters) {
setFilters(data.filters);
}
} else {
console.error('Failed to fetch owned cards');
}
} catch (error) {
console.error('Error fetching owned cards:', error);
} finally {
setLoading(false);
setLoadingMore(false);
loadingMoreRef.current = false;
}
};
// Load favorited cards
const loadFavoritedCards = async () => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch('/api/favorites?type=card', { headers });
if (response.ok) {
const data = await response.json();
// Handle both array and object responses
const favorites = Array.isArray(data) ? data : (data.favorites || []);
setFavoritedCards(new Set(favorites.map(fav => fav.item_id)));
}
} catch (error) {
console.error('Error loading favorited cards:', error);
// Set empty set on error to prevent crashes
setFavoritedCards(new Set());
}
};
// Initial load
useEffect(() => {
if (user) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- filter-driven reload via async fetchCards
fetchCards();
loadFavoritedCards();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- filter-driven reload; fetchCards closes over latest search state
}, [user, searchQuery, selectedTCG, selectedRarity, selectedSet, selectedValueRange]);
// Infinite scroll
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
const target = entries[0];
if (target.isIntersecting && hasMoreRef.current && !loadingMoreRef.current) {
fetchCards(true);
}
},
{ threshold: 0.1 }
);
const sentinel = document.getElementById('scroll-sentinel');
if (sentinel) {
observer.observe(sentinel);
}
return () => {
if (sentinel) {
observer.unobserve(sentinel);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps -- rebind observer when card list length changes
}, [cards]);
// Handle favorite toggle
const handleFavoriteToggle = async (cardId) => {
try {
const token = localStorage.getItem('auth_token');
const headers = {
'Content-Type': 'application/json',
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const isFavorited = favoritedCards.has(cardId);
if (isFavorited) {
// Remove from favorites
const response = await fetch('/api/favorites', {
method: 'DELETE',
headers,
body: JSON.stringify({
itemType: 'card',
itemId: cardId
})
});
if (response.ok) {
setFavoritedCards(prev => {
const newSet = new Set(prev);
newSet.delete(cardId);
return newSet;
});
}
} else {
// Add to favorites
const response = await fetch('/api/favorites', {
method: 'POST',
headers,
body: JSON.stringify({
itemType: 'card',
itemId: cardId
})
});
if (response.ok) {
setFavoritedCards(prev => {
const newSet = new Set(prev);
newSet.add(cardId);
return newSet;
});
}
}
} catch (error) {
console.error('Error toggling favorite:', error);
}
};
// Handle bulk actions
const handleAddToCollections = async (selectedCollectionIds) => {
// Implementation for adding cards to collections
console.log('Adding cards to collections:', selectedCollectionIds);
setShowCollectionModal(false);
setSelectedCards([]);
};
if (loading && cards.length === 0) {
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>
</div>
</Layout>
);
}
return (
<Layout user={user}>
{/* Header */}
<div className="px-4 sm:px-6 pt-6 pb-2">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold mb-2" style={{ color: 'var(--text-primary)' }}>
{VOCAB.MY_COLLECTION}
</h1>
<p className="text-base sm:text-lg" style={{ color: 'var(--text-secondary)' }}>
Browse and manage cards in {VOCAB.MY_COLLECTION}
</p>
</div>
<div className="flex space-x-2 sm:space-x-4">
<button
onClick={() => setViewMode('grid')}
className={`p-2 rounded-xl transition-all duration-200 ${
viewMode === 'grid'
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: viewMode === 'grid' ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: viewMode === 'grid' ? 'white' : 'var(--text-primary)'
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
</svg>
</button>
<button
onClick={() => setViewMode('list')}
className={`p-2 rounded-xl transition-all duration-200 ${
viewMode === 'list'
? 'shadow-lg'
: 'hover:shadow-md'
}`}
style={{
backgroundColor: viewMode === 'list' ? 'var(--accent-ember)' : 'var(--bg-tertiary)',
color: viewMode === 'list' ? 'white' : 'var(--text-primary)'
}}
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
</button>
</div>
</div>
</div>
{/* Filters */}
<div className="p-4 sm:p-6">
<div className="glass-panel rounded-2xl p-4 mb-6">
<div className="flex flex-wrap gap-4">
<div className="flex-1 min-w-[200px]">
<SearchBar
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onClear={() => setSearchQuery('')}
placeholder="Search your cards…"
/>
</div>
<select
value={selectedTCG}
onChange={(e) => setSelectedTCG(e.target.value)}
className="input-field"
>
<option value="all">All Games</option>
{filters.games.map(game => (
<option key={game} value={game}>{game}</option>
))}
</select>
<select
value={selectedRarity}
onChange={(e) => setSelectedRarity(e.target.value)}
className="input-field"
>
<option value="all">All Rarities</option>
{filters.rarities.map(rarity => (
<option key={rarity} value={rarity}>{rarity}</option>
))}
</select>
</div>
</div>
{/* Results Summary */}
<div className="flex items-center justify-between mb-4">
<p style={{ color: 'var(--text-secondary)' }}>
{pagination.total} cards in {VOCAB.MY_COLLECTION}
</p>
{selectedCards.length > 0 && (
<p style={{ color: 'var(--text-primary)' }}>
{selectedCards.length} cards selected
</p>
)}
</div>
{/* Bulk Selection Toolbar */}
{selectedCards.length > 0 && (
<BulkSelectionToolbar
selectedCount={selectedCards.length}
onAddToCollection={() => {
setCardsToAdd(selectedCards);
setShowCollectionModal(true);
}}
onClearSelection={() => setSelectedCards([])}
/>
)}
{/* Cards Grid */}
<div className={`
${viewMode === 'grid'
? 'grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4'
: 'space-y-4'
}
`}>
{cards.map((card) => (
<CardItem
key={card.id}
card={card}
viewMode={viewMode}
isSelected={selectedCards.includes(card.id)}
isFavorited={favoritedCards.has(card.id)}
onSelect={(cardId) => {
setSelectedCards(prev =>
prev.includes(cardId)
? prev.filter(id => id !== cardId)
: [...prev, cardId]
);
}}
onFavorite={(cardId) => {
handleFavoriteToggle(cardId);
}}
/>
))}
</div>
{/* Loading More */}
{loadingMore && (
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2" style={{ borderColor: 'var(--accent-ember)' }}></div>
</div>
)}
{/* Infinite Scroll Sentinel */}
{hasMore && <div id="scroll-sentinel" className="h-4"></div>}
{/* No More Cards */}
{!hasMore && cards.length > 0 && (
<div className="text-center py-8">
<p style={{ color: 'var(--text-secondary)' }}>
You&apos;ve reached the end of {VOCAB.MY_COLLECTION}!
</p>
</div>
)}
{/* No Cards Found */}
{cards.length === 0 && !loading && (
<div className="text-center py-20">
<div className="glass-panel w-16 h-16 mx-auto mb-4 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: 'var(--text-secondary)' }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
</svg>
</div>
<h3 className="text-lg font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>No cards in {VOCAB.MY_COLLECTION}</h3>
<p className="mb-4" style={{ color: 'var(--text-secondary)' }}>
{searchQuery ? 'Try adjusting your search filters' : `Start adding cards to ${VOCAB.MY_COLLECTION} by browsing available cards`}
</p>
<Link href="/cards">
<Button variant="primary" size="lg">
Browse All Cards
</Button>
</Link>
</div>
)}
</div>
{/* Collection Selection Modal */}
<CollectionSelectionModal
isOpen={showCollectionModal}
onClose={() => setShowCollectionModal(false)}
cards={cardsToAdd}
onAddToCollections={handleAddToCollections}
/>
</Layout>
);
}