deckhearth/pages/my-cards.js
varutasu 6d24db1a80
feat(design-system): hearth-gradient body bg + glass page header strips (#100)
Three prior PRs (#97 panels, #98 backdrop-filter, #99 box-shadow
composition) all landed correct CSS but the user reported the design
"still looks the same" on /my-cards and /dashboard. CDP diagnostic on
both production and a local dev build found the actual root causes:

1) The body background is a flat warm-white (#fefcf8). Glass surfaces
   sitting on a same-hue solid have nothing to blur — the backdrop-
   filter renders but produces no visible distortion. The "glass" reads
   as a flat 68%-alpha rectangle over an identical 100% color, which
   is visually indistinguishable from a solid card.

2) Pages with populated data (the user's /my-cards with 2 cards) have
   ZERO .glass-panel elements after PR #97's sweep. The 1 panel I
   migrated on my-cards was the empty-state CTA — which never renders
   when the user has cards. Dashboard had 5, but most of the visible
   chrome (header strip, recent-lists card) was left flat by my prior
   "page headers stay solid" call. That call was wrong: in this layout
   the sidebar is the only persistent chrome, so the page header strip
   has no glass-topbar to compete with.

Fix — two changes that compound:

(A) Hearth gradient on body. Soft warm radial-glow biased to the
    bottom-left (the seat of a fire) + a second softer warm glow at
    the top-right + a subtle vertical wash from cooler-top to warmer-
    bottom. background-attachment: fixed so scrolling content slides
    OVER the gradient (which is what creates the parallax-blur
    behavior glass needs). Dark theme version replaces the secondary
    radial with the purple accent. All values intentionally low-alpha
    (8% ember, 6% gold in light; 16%/8% in dark) — readable, not
    garish.

(B) New .page-header-glass utility for the recurring page-header-
    strip pattern. tint=high (it spans full content width and needs
    more visual weight than .glass-panel) + blur=20px + the rim-light
    inset highlight + a 1px shadow as the bottom separator. Applied
    via mechanical sweep to the 6 page header strips on:
      - /my-cards
      - /dashboard
      - /community/collections
      - components/CardsPageView (used by /cards)
      - components/CollectionsPageView (used by /collections)
      - components/CollectionPageView (used by /collection/[id])

After this PR, /my-cards (and every other authenticated page) will
show a distinctly translucent floating header strip with a soft top
highlight against the warm hearth gradient — i.e. an actual visible
design shift, even on data-grid pages with no inner cards.

Verified locally — npm run build, lint clean, vitest 104/104.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 09:12:25 -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="page-header-glass p-4 sm:p-6">
<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>
);
}