Add mobile layout: full-width list with bottom sheet card detail
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+107
-68
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user