Add mobile layout: full-width list with bottom sheet card detail
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-15 22:33:39 +02:00
parent 62bb3a5175
commit a4014182a0
5 changed files with 162 additions and 71 deletions
+107 -68
View File
@@ -3,10 +3,12 @@ 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' },
@@ -22,6 +24,9 @@ function typeBadge(type) {
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);
@@ -33,6 +38,7 @@ function HomePage() {
const { expandedCardId, setExpandedCardId, cardImages, setCardImage, ownedAmounts } = useContext(CardContext);
const [artworkIndex, setArtworkIndex] = useState(0);
const isMobile = useMediaQuery('(max-width: 768px)');
const debouncedSearch = useDebounce(searchTerm, 250);
const loadCards = useCallback(() => fetchCards().then(setCards), []);
@@ -48,7 +54,6 @@ function HomePage() {
const imageIds = cardImages[expandedCardId]?.ids;
const imageId = imageIds?.[artworkIndex];
if (cardImages[expandedCardId]?.blobs[artworkIndex]) return;
// imageId may be undefined on first load (ids not yet fetched)
fetchCardImage(expandedCardId, imageId)
.then(({ image, image_ids }) => setCardImage(expandedCardId, artworkIndex, image, image_ids))
.catch(err => console.error('Failed to load card image', err));
@@ -61,14 +66,14 @@ function HomePage() {
}, 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 expandedCard = cards.find(c => c.id === expandedCardId);
const filteredCards = useMemo(() => {
let result = cards;
if (debouncedSearch) {
@@ -80,7 +85,6 @@ function HomePage() {
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));
@@ -90,18 +94,88 @@ function HomePage() {
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: '2rem', padding: '6px 16px', flexShrink: 0,
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',
}}>
<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>
{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>
@@ -110,9 +184,9 @@ function HomePage() {
{/* Main panels */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
{/* Left — card list */}
<div style={{ flex: 2, display: 'flex', flexDirection: 'column', borderRight: '1px solid #2a2a2a', overflow: 'hidden' }}>
<div style={{ padding: '10px 16px', borderBottom: '1px solid #2a2a2a', flexShrink: 0 }}>
{/* 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}
@@ -126,68 +200,33 @@ function HomePage() {
<Virtuoso
style={{ flex: 1 }}
data={filteredCards}
itemContent={(_, card) => <CardRow card={card} />}
itemContent={(_, card) => <CardRow card={card} isMobile={isMobile} />}
/>
</div>
{/* Right — card detail */}
<div style={{ flex: 1, padding: '16px', overflowY: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '10px' }}>
{expandedCard ? (
<>
{(() => {
const imgs = cardImages[expandedCardId];
const blob = imgs?.blobs[artworkIndex];
const ids = imgs?.ids ?? [];
return (
<div style={{ position: 'relative', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{blob ? (
<img src={blob} alt={expandedCard.name} style={{ maxWidth: '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' }}>{expandedCard.name}</span>
<div style={{ display: 'flex', gap: '6px', flexWrap: 'wrap' }}>
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', fontWeight: 500, ...typeBadge(expandedCard.type) }}>
{expandedCard.type}
</span>
{expandedCard.attribute && (
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', background: '#202020', color: '#888' }}>
{expandedCard.attribute}
</span>
)}
{expandedCard.race && (
<span style={{ fontSize: '11px', padding: '2px 8px', borderRadius: '10px', background: '#202020', color: '#888' }}>
{expandedCard.race}
</span>
)}
</div>
{expandedCard.level != null && (
<span style={{ fontSize: '12px', color: '#888' }}>
{'★'.repeat(Math.min(expandedCard.level, 13))} Lv.{expandedCard.level}
</span>
)}
{expandedCard.link_val != null && (
<span style={{ fontSize: '12px', color: '#888' }}>Link {expandedCard.link_val}</span>
)}
</div>
</>
) : (
<span style={{ color: '#333', fontSize: '13px', marginTop: '2rem' }}>Click a card to see details</span>
)}
</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 className="bottom-sheet">
<div className="sheet-handle" />
<div style={{ padding: '12px 16px', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '12px' }}>
{cardDetailContent(expandedCard)}
</div>
</div>
</>
)}
</div>
);
}