Files
YuGiOh-Database-Frontend/src/pages/HomePage.jsx
T
2026-05-15 22:44:11 +02:00

271 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useState, useContext, useMemo, useCallback } from 'react';
import { Virtuoso } from 'react-virtuoso';
import { CardContext } from '../context/CardContext';
import { fetchCards, fetchCardImage } from '../services/api';
import { useDebounce } from '../hooks/useDebounce';
import { useMediaQuery } from '../hooks/useMediaQuery';
import CardRow from '../components/CardRow/CardRow';
import SearchBar from '../components/SearchBar/SearchBar';
import FilterBar from '../components/FilterBar/FilterBar';
import Footer from '../components/Footer/Footer';
import PrintingRow from '../components/PrintingRow/PrintingRow';
const BADGE = {
monster: { background: '#3a2000', color: '#d4820a' },
spell: { background: '#002820', color: '#10a06a' },
trap: { background: '#280020', color: '#c040a0' },
other: { background: '#202020', color: '#666' },
};
function typeBadge(type) {
const t = (type || '').toLowerCase();
if (t.includes('monster')) return BADGE.monster;
if (t.includes('spell')) return BADGE.spell;
if (t.includes('trap')) return BADGE.trap;
return BADGE.other;
}
const MOBILE_PRINTING_COLS = '60px 1fr 100px 80px';
const DESKTOP_PRINTING_COLS = '80px 1fr 150px 100px';
function HomePage() {
const [cards, setCards] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [searchTerm, setSearchTerm] = useState('');
const [typeFilter, setTypeFilter] = useState('All');
const [ownedOnly, setOwnedOnly] = useState(false);
const [sortBy, setSortBy] = useState('name');
const { expandedCardId, setExpandedCardId, cardImages, setCardImage, ownedAmounts } = useContext(CardContext);
const [artworkIndex, setArtworkIndex] = useState(0);
const isMobile = useMediaQuery('(max-width: 768px)');
const sheetRef = React.useRef(null);
const handleRef = React.useRef(null);
const dragStartY = React.useRef(null);
React.useEffect(() => {
const handle = handleRef.current;
if (!handle) return;
const onStart = e => { dragStartY.current = e.touches[0].clientY; };
const onMove = e => {
e.preventDefault();
const dy = e.touches[0].clientY - dragStartY.current;
if (dy > 0 && sheetRef.current) sheetRef.current.style.transform = `translateY(${dy}px)`;
};
const onEnd = e => {
const dy = e.changedTouches[0].clientY - dragStartY.current;
if (dy > 80) {
setExpandedCardId(null);
} else if (sheetRef.current) {
sheetRef.current.style.transition = 'transform 0.2s ease';
sheetRef.current.style.transform = '';
setTimeout(() => { if (sheetRef.current) sheetRef.current.style.transition = ''; }, 200);
}
};
handle.addEventListener('touchstart', onStart, { passive: true });
handle.addEventListener('touchmove', onMove, { passive: false });
handle.addEventListener('touchend', onEnd, { passive: true });
return () => {
handle.removeEventListener('touchstart', onStart);
handle.removeEventListener('touchmove', onMove);
handle.removeEventListener('touchend', onEnd);
};
}, [expandedCardId, setExpandedCardId]);
const debouncedSearch = useDebounce(searchTerm, 250);
const loadCards = useCallback(() => fetchCards().then(setCards), []);
useEffect(() => {
loadCards().catch(err => setError(err.message)).finally(() => setLoading(false));
}, [loadCards]);
useEffect(() => { setArtworkIndex(0); }, [expandedCardId]);
useEffect(() => {
if (!expandedCardId) return;
const imageIds = cardImages[expandedCardId]?.ids;
const imageId = imageIds?.[artworkIndex];
if (cardImages[expandedCardId]?.blobs[artworkIndex]) return;
fetchCardImage(expandedCardId, imageId)
.then(({ image, image_ids }) => setCardImage(expandedCardId, artworkIndex, image, image_ids))
.catch(err => console.error('Failed to load card image', err));
}, [expandedCardId, artworkIndex, cardImages, setCardImage]);
const getTotal = useCallback((card) =>
card.printings?.reduce((sum, p) => {
const key = `${p.set_id}-${p.rarity_id}`;
return sum + (ownedAmounts[card.id]?.[key] ?? p.amount_owned ?? 0);
}, 0) ?? 0
, [ownedAmounts]);
const expandedCard = cards.find(c => c.id === expandedCardId);
const stats = useMemo(() => ({
total: cards.length,
uniqueOwned: cards.filter(c => getTotal(c) > 0).length,
totalCopies: cards.reduce((s, c) => s + getTotal(c), 0),
}), [cards, getTotal]);
const filteredCards = useMemo(() => {
let result = cards;
if (debouncedSearch) {
const q = debouncedSearch.toLowerCase();
result = result.filter(c => c.name.toLowerCase().includes(q));
}
if (typeFilter !== 'All') {
const q = typeFilter.toLowerCase();
result = result.filter(c => c.type.toLowerCase().includes(q));
}
if (ownedOnly) result = result.filter(c => getTotal(c) > 0);
const sorted = [...result];
if (sortBy === 'owned') sorted.sort((a, b) => getTotal(b) - getTotal(a));
else sorted.sort((a, b) => a.name.localeCompare(b.name));
return sorted;
}, [cards, debouncedSearch, typeFilter, ownedOnly, sortBy, getTotal]);
if (loading) return <p style={{ padding: '1rem', color: '#666' }}>Loading cards</p>;
if (error) return <p style={{ padding: '1rem', color: '#c04040' }}>Error: {error}</p>;
const cardDetailContent = (card) => {
if (!card) return null;
const imgs = cardImages[card.id];
const blob = imgs?.blobs[artworkIndex];
const ids = imgs?.ids ?? [];
const printingCols = isMobile ? MOBILE_PRINTING_COLS : DESKTOP_PRINTING_COLS;
return (
<>
<div style={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{blob
? <img src={blob} alt={card.name} style={{ maxWidth: isMobile ? '60%' : '100%' }} />
: <div style={{ color: '#444', fontSize: '12px', padding: '2rem' }}>Loading image</div>
}
{ids.length > 1 && (
<div style={{ position: 'absolute', bottom: '6px', display: 'flex', alignItems: 'center', gap: '8px' }}>
<button className="icon-btn" onClick={() => setArtworkIndex(i => Math.max(0, i - 1))} disabled={artworkIndex === 0}></button>
<span style={{ fontSize: '11px', color: '#555' }}>{artworkIndex + 1} / {ids.length}</span>
<button className="icon-btn" onClick={() => setArtworkIndex(i => Math.min(ids.length - 1, i + 1))} disabled={artworkIndex === ids.length - 1}></button>
</div>
)}
</div>
<div style={{ width: '100%', borderTop: '1px solid #222', paddingTop: '10px', display: 'flex', flexDirection: 'column', gap: '5px' }}>
<span style={{ fontSize: '15px', fontWeight: 600, color: '#e0e0e0' }}>{card.name}</span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', fontWeight: 500, ...typeBadge(card.type) }}>{card.type}</span>
{card.attribute && <span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', background: '#202020', color: '#888' }}>{card.attribute}</span>}
{card.race && <span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', background: '#202020', color: '#888' }}>{card.race}</span>}
</div>
{card.level != null && <span style={{ fontSize: '12px', color: '#888' }}>{'★'.repeat(Math.min(card.level, 13))} Lv.{card.level}</span>}
{card.link_val != null && <span style={{ fontSize: '12px', color: '#888' }}>Link {card.link_val}</span>}
</div>
{isMobile && card.printings?.length > 0 && (
<div style={{ width: '100%', borderTop: '1px solid #222', paddingTop: '6px' }}>
<div style={{
display: 'grid', gridTemplateColumns: printingCols,
gap: '8px', padding: '5px 12px',
fontSize: '10px', color: '#444',
textTransform: 'uppercase', letterSpacing: '0.05em',
borderBottom: '1px solid #222',
}}>
<span>Code</span><span>Set</span><span>Rarity</span><span>Owned</span>
</div>
{card.printings.map((printing, i) => (
<PrintingRow
key={`${card.id}-${printing.set_id}-${printing.rarity_id}`}
card_id={card.id}
printing={printing}
cols={printingCols}
zebra={i % 2 === 1}
/>
))}
</div>
)}
</>
);
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
{/* Stats bar */}
<div style={{
display: 'flex', alignItems: 'center', gap: isMobile ? '1rem' : '2rem',
padding: isMobile ? '6px 12px' : '6px 16px', flexShrink: 0,
background: '#1c1c1c', borderBottom: '1px solid #2a2a2a',
fontSize: '12px', color: '#666',
}}>
{isMobile ? (
<>
<span><strong style={{ color: '#e0e0e0' }}>{stats.total.toLocaleString()}</strong> cards</span>
<span><strong style={{ color: '#e0e0e0' }}>{stats.uniqueOwned.toLocaleString()}</strong> owned</span>
<span><strong style={{ color: '#e0e0e0' }}>{stats.totalCopies.toLocaleString()}</strong> copies</span>
</>
) : (
<>
<span><strong style={{ color: '#e0e0e0' }}>{stats.total.toLocaleString()}</strong> cards in DB</span>
<span><strong style={{ color: '#e0e0e0' }}>{stats.uniqueOwned.toLocaleString()}</strong> unique owned</span>
<span><strong style={{ color: '#e0e0e0' }}>{stats.totalCopies.toLocaleString()}</strong> total copies</span>
</>
)}
<div style={{ marginLeft: 'auto' }}>
<Footer onImportComplete={loadCards} />
</div>
</div>
{/* Main panels */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Card list */}
<div style={{ flex: isMobile ? 1 : 2, display: 'flex', flexDirection: 'column', borderRight: isMobile ? 'none' : '1px solid #2a2a2a', overflow: 'hidden' }}>
<div style={{ padding: isMobile ? '8px 12px' : '10px 16px', borderBottom: '1px solid #2a2a2a', flexShrink: 0 }}>
<SearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
<FilterBar
typeFilter={typeFilter} setTypeFilter={setTypeFilter}
ownedOnly={ownedOnly} setOwnedOnly={setOwnedOnly}
sortBy={sortBy} setSortBy={setSortBy}
/>
</div>
<div style={{ padding: '4px 16px', fontSize: '11px', color: '#444', flexShrink: 0, borderBottom: '1px solid #1a1a1a' }}>
{filteredCards.length.toLocaleString()} cards
</div>
<Virtuoso
style={{ flex: 1 }}
data={filteredCards}
itemContent={(_, card) => <CardRow card={card} isMobile={isMobile} />}
/>
</div>
{/* Desktop: right panel */}
{!isMobile && (
<div style={{ flex: 1, padding: '16px', overflowY: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
{expandedCard
? cardDetailContent(expandedCard)
: <span style={{ color: '#333', fontSize: '13px', marginTop: '2rem' }}>Click a card to see details</span>
}
</div>
)}
</div>
{/* Mobile: bottom sheet */}
{isMobile && expandedCard && (
<>
<div className="sheet-backdrop" onClick={() => setExpandedCardId(null)} />
<div ref={sheetRef} className="bottom-sheet">
<div ref={handleRef} className="sheet-handle-area">
<div className="sheet-handle" />
</div>
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
{cardDetailContent(expandedCard)}
</div>
</div>
</>
)}
</div>
);
}
export default HomePage;