deckhearth/pages/my-cards.js
varutasu e6e778080a
feat(design-system): redesign v2 #3 — TopSearchBar + Cmd+K + sweep page-header-glass (#105)
Sub-convoy #3 from .convoys/redesign-v2-from-mockups.md (umbrella
§ 7.3 — locked: sweep to ALL authenticated pages this convoy).

What ships:

- components/ui/TopSearchBar.js — the top horizontal chrome strip
  from the mockup. Layout: prominent search input on left (with
  magnifier icon + Cmd+K/Ctrl+K hint pill that adapts to platform)
  + notification bell with red badge (hidden when count=0) + mail
  icon + compact user-menu chip (gradient-tile avatar + display name
  + chevron). Avatar reads user.username with a fallback initial.
  Renders null for unauthenticated visitors (public marketing pages
  use their own header).

- components/ui/CommandPaletteModal.js — the surface that opens on
  ⌘K / Ctrl+K. Single search input, auto-focused. Enter submits to
  /cards?q=<query>. 3 quick-action buttons (Dashboard / Cards /
  Scanner) below the input. Eschews live-result preview, recent-
  search storage, and federated-search ranking; those are deferred
  to a follow-up convoy per umbrella § 7.2.

- components/Layout.js: TopSearchBar mounted in the main-content
  column ABOVE <main> for authenticated users (drops the legacy
  showSearch prop dependency — the prop stays for back-compat but
  no longer drives the header's visibility). Global keydown listener
  attached at Layout scope, toggles the CommandPaletteModal on
  ⌘K/Ctrl+K (preventDefault on the shortcut so the browser's native
  bookmark/search shortcut doesn't fire). The legacy <header>
  block that rendered an inline search input is removed; that
  surface is replaced by TopSearchBar + CommandPaletteModal.

- page-header-glass call-site sweep (umbrella § 7.3 contract:
  "no call site references it after this convoy"):
  - pages/dashboard.js
  - pages/my-cards.js
  - pages/community/collections.js
  - components/CollectionsPageView.js
  - components/CollectionPageView.js
  - components/CardsPageView.js
  Each `page-header-glass p-4 sm:p-6` is replaced with plain content
  padding (`px-4 sm:px-6 pt-6 pb-2`). Page titles + actions stay
  exactly where they were inside the content area; the glass chrome
  that previously framed them is now provided by TopSearchBar above.
  The .page-header-glass utility class stays in styles/globals.css
  (a downstream sweep convoy can remove it once the unused-CSS lint
  catches it).

- components/ui/index.js: barrel export updated with TopSearchBar +
  CommandPaletteModal.

Lint fix:
- CommandPaletteModal initially used useEffect(setQuery(''), [open])
  to reset the input on open; that hits the react-hooks/set-state-
  in-effect rule (we added the rule in fix-auth-bypass Brief 5). Use
  the "during render with previous-state tracking" pattern that
  NavigationContent uses (lines 168-178 of components/Layout.js)
  for the same purpose. No useEffect required.

Tests:
- npm run test:run: 113/113 (was 110; +3 new — implicit Layout
  tree-render coverage of the new TopSearchBar mount paths).
- npm run lint: clean (1 pre-existing unused-disable warning).
- npm run build: green.

Next: sub-convoy #6 (card-grid outer-glow), #7 (dashboard layout
rebuild), #8 (right-rail Card Spotlight).

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

462 lines
No EOL
16 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 } 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" style={{ backgroundColor: 'var(--bg-primary)' }}>
<div className="flex flex-wrap gap-4 mb-6">
<input
type="text"
placeholder="Search your cards..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="flex-1 min-w-[200px] px-4 py-2 rounded-xl border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
/>
<select
value={selectedTCG}
onChange={(e) => setSelectedTCG(e.target.value)}
className="px-4 py-2 rounded-xl border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
<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="px-4 py-2 rounded-xl border"
style={{
backgroundColor: 'var(--bg-secondary)',
borderColor: 'var(--border)',
color: 'var(--text-primary)'
}}
>
<option value="all">All Rarities</option>
{filters.rarities.map(rarity => (
<option key={rarity} value={rarity}>{rarity}</option>
))}
</select>
</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>
);
}