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>
450 lines
No EOL
15 KiB
JavaScript
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'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>
|
|
);
|
|
}
|