diff --git a/.gitignore b/.gitignore index d63abad..58497fd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env .env.local +mockups/ # Logs logs diff --git a/package.json b/package.json index b62b2ee..9be697a 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,7 @@ "dependencies": { "react": "^19.2.4", "react-dom": "^19.2.4", - "react-virtuoso": "^4.18.3", - "react-window": "^2.2.7" + "react-virtuoso": "^4.18.3" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/src/App.css b/src/App.css index 65b74a9..3c6824e 100644 --- a/src/App.css +++ b/src/App.css @@ -1,277 +1 @@ -.counter { - font-size: 16px; - padding: 5px 10px; - border-radius: 5px; - color: var(--accent); - background: var(--accent-bg); - border: 2px solid transparent; - transition: border-color 0.3s; - margin-bottom: 24px; - - &:hover { - border-color: var(--accent-border); - } - &:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; - } -} - -.hero { - position: relative; - - .base, - .framework, - .vite { - inset-inline: 0; - margin: 0 auto; - } - - .base { - width: 170px; - position: relative; - z-index: 0; - } - - .framework, - .vite { - position: absolute; - } - - .framework { - z-index: 1; - top: 34px; - height: 28px; - transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) - scale(1.4); - } - - .vite { - z-index: 0; - top: 107px; - height: 26px; - width: auto; - transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) - scale(0.8); - } -} - -#center { - display: flex; - flex-direction: column; - gap: 25px; - place-content: center; - place-items: center; - flex-grow: 1; - - @media (max-width: 1024px) { - padding: 32px 20px 24px; - gap: 18px; - } -} - -#next-steps { - display: flex; - border-top: 1px solid var(--border); - text-align: left; - - & > div { - flex: 1 1 0; - padding: 32px; - @media (max-width: 1024px) { - padding: 24px 20px; - } - } - - .icon { - margin-bottom: 16px; - width: 22px; - height: 22px; - } - - @media (max-width: 1024px) { - flex-direction: column; - text-align: center; - } -} - -#docs { - border-right: 1px solid var(--border); - - @media (max-width: 1024px) { - border-right: none; - border-bottom: 1px solid var(--border); - } -} - -#next-steps ul { - list-style: none; - padding: 0; - display: flex; - gap: 8px; - margin: 32px 0 0; - - .logo { - height: 18px; - } - - a { - color: var(--text-h); - font-size: 16px; - border-radius: 6px; - background: var(--social-bg); - display: flex; - padding: 6px 12px; - align-items: center; - gap: 8px; - text-decoration: none; - transition: box-shadow 0.3s; - - &:hover { - box-shadow: var(--shadow); - } - .button-icon { - height: 18px; - width: 18px; - } - } - - @media (max-width: 1024px) { - margin-top: 20px; - flex-wrap: wrap; - justify-content: center; - - li { - flex: 1 1 calc(50% - 8px); - } - - a { - width: 100%; - justify-content: center; - box-sizing: border-box; - } - } -} - -#spacer { - height: 88px; - border-top: 1px solid var(--border); - @media (max-width: 1024px) { - height: 48px; - } -} - -.ticks { - position: relative; - width: 100%; - - &::before, - &::after { - content: ''; - position: absolute; - top: -4.5px; - border: 5px solid transparent; - } - - &::before { - left: 0; - border-left-color: var(--border); - } - &::after { - right: 0; - border-right-color: var(--border); - } -} - -.footer { - position: sticky; - bottom: 0; - width: fit-content; - padding: 0.5rem 1rem; - background: #2a2a2a; - color: var(--text-h, #f0f0f0); - display: flex; - align-items: center; - gap: 1rem; - border-radius: 8px 8px 0 0; - border: 1px solid #666 !important; - border-bottom: none !important; - margin-right: 1.5rem; - z-index: 10; -} - -.footer button { - background: var(--accent-bg, #444); - color: var(--accent, #fff); - border: 1px solid var(--accent-border, #666); - border-radius: 4px; - padding: 4px 8px; - cursor: pointer; - transition: background 0.2s, border-color 0.2s; -} - -.footer button:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.footer button:hover:not(:disabled) { - background: var(--accent, #fff); - color: var(--accent-bg, #444); -} - -/* Modal overlay */ -.modal-backdrop { - position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.55); - backdrop-filter: blur(2px); - z-index: 1000; - animation: fadeIn 0.15s ease; -} - -@keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } -} - -/* Popup modal */ -.import-modal { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - background: var(--card-bg, #1e1e1e); - color: var(--text-h, #f0f0f0); - padding: 2rem 2.5rem; - border-radius: 12px; - border: 1px solid var(--border, #555); - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); - z-index: 1001; - max-width: 90%; - min-width: 300px; - text-align: center; - animation: popIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); -} - -@keyframes popIn { - from { opacity: 0; transform: translate(-50%, -48%) scale(0.92); } - to { opacity: 1; transform: translate(-50%, -50%) scale(1); } -} - -.import-modal button { - margin-top: 1.25rem; - padding: 0.5rem 1.5rem; - cursor: pointer; - background: var(--accent-bg, #444); - color: var(--accent, #fff); - border: 1px solid var(--accent-border, #666); - border-radius: 6px; - font-size: 0.95rem; - transition: background 0.2s; -} - -.import-modal button:hover { - background: var(--accent, #fff); - color: var(--accent-bg, #444); -} \ No newline at end of file +/* unused – kept so Vite doesn't error if it's ever imported */ diff --git a/src/App.jsx b/src/App.jsx index 7164935..5e68296 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -2,11 +2,7 @@ import React from 'react'; import HomePage from './pages/HomePage'; function App() { - return ( -
- -
- ); + return ; } export default App; diff --git a/src/components/CardRow/CardRow.jsx b/src/components/CardRow/CardRow.jsx index 096bc74..6053c2d 100644 --- a/src/components/CardRow/CardRow.jsx +++ b/src/components/CardRow/CardRow.jsx @@ -2,39 +2,76 @@ import React, { useContext } from 'react'; import { CardContext } from '../../store/CardContext'; import PrintingRow from '../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 PRINTING_COLS = '80px 1fr 150px 100px'; + function CardRow({ card }) { const { expandedCardId, setExpandedCardId, ownedAmounts } = useContext(CardContext); const isExpanded = expandedCardId === card.id; - const toggleExpand = () => setExpandedCardId(isExpanded ? null : card.id); - - // ✅ Calculate total owned across all printings using combined key const totalOwned = card.printings?.reduce((sum, p) => { const key = `${p.set_id}-${p.rarity_id}`; - const owned = ownedAmounts[card.id]?.[key] ?? p.amount_owned ?? 0; - return sum + owned; + return sum + (ownedAmounts[card.id]?.[key] ?? p.amount_owned ?? 0); }, 0) ?? 0; return ( -
+
setExpandedCardId(isExpanded ? null : card.id)} + style={{ + display: 'flex', alignItems: 'center', gap: '12px', + padding: '9px 16px', + background: isExpanded ? '#1c1c1c' : 'transparent', + }} > - {card.name} -
- {card.type} - Total owned: {totalOwned} -
+ {card.name} + + {card.type} + + 0 ? '#aaa' : '#444', + }}> + {totalOwned > 0 ? `×${totalOwned}` : '—'} +
{isExpanded && card.printings?.length > 0 && ( -
+
+
+ CodeSetRarityOwned +
{card.printings.map(printing => ( ))}
@@ -43,4 +80,4 @@ function CardRow({ card }) { ); } -export default CardRow; \ No newline at end of file +export default CardRow; diff --git a/src/components/FilterBar/FilterBar.jsx b/src/components/FilterBar/FilterBar.jsx index 195e3bb..329d652 100644 --- a/src/components/FilterBar/FilterBar.jsx +++ b/src/components/FilterBar/FilterBar.jsx @@ -1,31 +1,28 @@ import React from 'react'; -const TYPES = ['All', 'Monster', 'Spell', 'Trap']; +const CHIPS = [ + { label: 'All', activeClass: 'active' }, + { label: 'Monster', activeClass: 'active-m' }, + { label: 'Spell', activeClass: 'active-s' }, + { label: 'Trap', activeClass: 'active-t' }, +]; function FilterBar({ typeFilter, setTypeFilter, ownedOnly, setOwnedOnly, sortBy, setSortBy }) { return ( -
-
- {TYPES.map(t => ( +
+
+ {CHIPS.map(({ label, activeClass }) => ( ))}
-
); } -export default SearchBar; \ No newline at end of file +export default SearchBar; diff --git a/src/hooks/useDebounce.js b/src/hooks/useDebounce.js new file mode 100644 index 0000000..6194a0e --- /dev/null +++ b/src/hooks/useDebounce.js @@ -0,0 +1,10 @@ +import { useState, useEffect } from 'react'; + +export function useDebounce(value, delay = 250) { + const [debounced, setDebounced] = useState(value); + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(id); + }, [value, delay]); + return debounced; +} diff --git a/src/index.css b/src/index.css index f42ee28..449dd9a 100644 --- a/src/index.css +++ b/src/index.css @@ -1,110 +1,94 @@ -:root { - --text: #6b6375; - --text-h: #08060d; - --bg: #fff; - --border: #e5e4e7; - --code-bg: #f4f3ec; - --accent: #aa3bff; - --accent-bg: rgba(170, 59, 255, 0.1); - --accent-border: rgba(170, 59, 255, 0.5); - --social-bg: rgba(244, 243, 236, 0.5); - --shadow: - rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } - --sans: system-ui, 'Segoe UI', Roboto, sans-serif; - --heading: system-ui, 'Segoe UI', Roboto, sans-serif; - --mono: ui-monospace, Consolas, monospace; - - font: 18px/145% var(--sans); - letter-spacing: 0.18px; - color-scheme: light dark; - color: var(--text); - background: var(--bg); - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; - - @media (max-width: 1024px) { - font-size: 16px; - } -} - -@media (prefers-color-scheme: dark) { - :root { - --text: #9ca3af; - --text-h: #f3f4f6; - --bg: #16171d; - --border: #2e303a; - --code-bg: #1f2028; - --accent: #c084fc; - --accent-bg: rgba(192, 132, 252, 0.15); - --accent-border: rgba(192, 132, 252, 0.5); - --social-bg: rgba(47, 48, 58, 0.5); - --shadow: - rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; - } - - #social .button-icon { - filter: invert(1) brightness(2); - } -} +html, body, #root { height: 100%; } body { - margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background: #111; + color: #e0e0e0; + font-size: 14px; + -webkit-font-smoothing: antialiased; } -#root { - width: 100%; - margin: 0; - text-align: left; /* optional: better for layout apps */ - min-height: 100svh; - display: flex; - flex-direction: column; - box-sizing: border-box; -} +/* Card row — :hover can't be done with inline styles */ +.card-row-header:hover { background: #1a1a1a !important; } +/* Filter chips */ +.filter-chip { + padding: 3px 12px; + border-radius: 20px; + border: 1px solid #333; + background: transparent; + color: #666; + font-size: 12px; + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} +.filter-chip:hover { border-color: #555; color: #bbb; } +.filter-chip.active { background: #e0e0e0; color: #111; border-color: #e0e0e0; } +.filter-chip.active-m { background: #c07020; color: #fff; border-color: #c07020; } +.filter-chip.active-s { background: #1a7a5e; color: #fff; border-color: #1a7a5e; } +.filter-chip.active-t { background: #7a2060; color: #fff; border-color: #7a2060; } -h1, -h2 { - font-family: var(--heading); - font-weight: 500; - color: var(--text-h); -} - -h1 { - font-size: 56px; - letter-spacing: -1.68px; - margin: 32px 0; - @media (max-width: 1024px) { - font-size: 36px; - margin: 20px 0; - } -} -h2 { - font-size: 24px; - line-height: 118%; - letter-spacing: -0.24px; - margin: 0 0 8px; - @media (max-width: 1024px) { - font-size: 20px; - } -} -p { - margin: 0; -} - -code, -.counter { - font-family: var(--mono); - display: inline-flex; +/* Amount +/− buttons */ +.icon-btn { + width: 22px; + height: 22px; border-radius: 4px; - color: var(--text-h); + border: 1px solid #333; + background: #222; + color: #aaa; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; } +.icon-btn:hover:not(:disabled) { background: #2e2e2e; border-color: #444; } +.icon-btn:disabled { opacity: 0.4; cursor: default; } -code { - font-size: 15px; - line-height: 135%; - padding: 4px 8px; - background: var(--code-bg); +/* Import modal */ +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(2px); + z-index: 1000; + animation: fadeIn 0.15s ease; } +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } + +.import-modal { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: #1e1e1e; + color: #e0e0e0; + padding: 2rem 2.5rem; + border-radius: 12px; + border: 1px solid #333; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.7); + z-index: 1001; + max-width: 90%; + min-width: 300px; + text-align: center; + animation: popIn 0.2s cubic-bezier(0.34, 1.56, 0.64, 1); +} +@keyframes popIn { + from { opacity: 0; transform: translate(-50%, -48%) scale(0.92); } + to { opacity: 1; transform: translate(-50%, -50%) scale(1); } +} +.import-modal button { + margin-top: 1.25rem; + padding: 0.4rem 1.4rem; + cursor: pointer; + background: #2a2a2a; + color: #e0e0e0; + border: 1px solid #444; + border-radius: 6px; + font-size: 0.9rem; + transition: background 0.15s; +} +.import-modal button:hover { background: #333; } diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 8768590..dfca30c 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,41 +1,43 @@ import React, { useEffect, useState, useContext, useMemo, useCallback } from 'react'; import { Virtuoso } from 'react-virtuoso'; +import { CardContext } from '../store/CardContext'; +import { fetchCards, fetchCardImage } from '../services/api'; +import { useDebounce } from '../hooks/useDebounce'; import CardRow from '../components/CardRow/CardRow'; import SearchBar from '../components/SearchBar/SearchBar'; import FilterBar from '../components/FilterBar/FilterBar'; -import { CardContext } from '../store/CardContext'; -import { fetchCards, fetchCardImage } from '../services/api'; import Footer from '../components/Footer/Footer'; -function useDebouncedValue(value, delay = 250) { - const [debounced, setDebounced] = useState(value); - useEffect(() => { - const handler = setTimeout(() => setDebounced(value), delay); - return () => clearTimeout(handler); - }, [value, delay]); - return debounced; +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; } function HomePage() { const [cards, setCards] = useState([]); - const [searchTerm, setSearchTerm] = 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, cardImages, setCardImage, ownedAmounts } = useContext(CardContext); - const debouncedSearchTerm = useDebouncedValue(searchTerm, 250); + const { expandedCardId, setExpandedCardId, cardImages, setCardImage, ownedAmounts } = useContext(CardContext); + const debouncedSearch = useDebounce(searchTerm, 250); - const loadCards = useCallback(() => { - return fetchCards().then(data => setCards(data)); - }, []); + const loadCards = useCallback(() => fetchCards().then(setCards), []); useEffect(() => { - loadCards() - .catch(err => setError(err.message)) - .finally(() => setLoading(false)); + loadCards().catch(err => setError(err.message)).finally(() => setLoading(false)); }, [loadCards]); useEffect(() => { @@ -45,7 +47,7 @@ function HomePage() { .catch(err => console.error('Failed to load card image', err)); }, [expandedCardId, cardImages, setCardImage]); - const getCardTotal = useCallback((card) => + 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); @@ -53,113 +55,115 @@ function HomePage() { , [ownedAmounts]); const stats = useMemo(() => ({ - totalCards: cards.length, - ownedUnique: cards.filter(c => getCardTotal(c) > 0).length, - totalCopies: cards.reduce((sum, c) => sum + getCardTotal(c), 0), - }), [cards, getCardTotal]); + 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 (debouncedSearchTerm) { - const lower = debouncedSearchTerm.toLowerCase(); - result = result.filter(c => c.name.toLowerCase().includes(lower)); + if (debouncedSearch) { + const q = debouncedSearch.toLowerCase(); + result = result.filter(c => c.name.toLowerCase().includes(q)); } - if (typeFilter !== 'All') { - const lower = typeFilter.toLowerCase(); - result = result.filter(c => c.type.toLowerCase().includes(lower)); - } - - if (ownedOnly) { - result = result.filter(c => getCardTotal(c) > 0); + 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) => getCardTotal(b) - getCardTotal(a)); - } else { - sorted.sort((a, b) => a.name.localeCompare(b.name)); - } - + if (sortBy === 'owned') sorted.sort((a, b) => getTotal(b) - getTotal(a)); + else sorted.sort((a, b) => a.name.localeCompare(b.name)); return sorted; - }, [cards, debouncedSearchTerm, typeFilter, ownedOnly, sortBy, getCardTotal]); - - if (loading) return

Loading cards...

; - if (error) return

Error: {error}

; + }, [cards, debouncedSearch, typeFilter, ownedOnly, sortBy, getTotal]); const expandedCard = cards.find(c => c.id === expandedCardId); + if (loading) return

Loading cards…

; + if (error) return

Error: {error}

; + return ( -
+
+ {/* Stats bar */}
- {stats.totalCards.toLocaleString()} cards in DB - {stats.ownedUnique.toLocaleString()} unique owned - {stats.totalCopies.toLocaleString()} total copies + {stats.total.toLocaleString()} cards in DB + {stats.uniqueOwned.toLocaleString()} unique owned + {stats.totalCopies.toLocaleString()} total copies
- {/* Main content */} + {/* Main panels */}
- {/* Left panel */} -
- - -
+ + {/* Left — card list */} +
+
+ + +
+
{filteredCards.length.toLocaleString()} cards
} + itemContent={(_, card) => } />
- {/* Right panel */} -
- {expandedCardId && expandedCard ? ( + {/* Right — card detail */} +
+ {expandedCard ? ( <> {cardImages[expandedCardId] ? ( - {expandedCard.name} + {expandedCard.name} ) : ( -

Loading image...

- )} -

{expandedCard.name}

-

- {expandedCard.type} - {expandedCard.race && ` · ${expandedCard.race}`} - {expandedCard.attribute && ` · ${expandedCard.attribute}`} -

- {expandedCard.level != null && ( -

- {'★'.repeat(Math.min(expandedCard.level, 13))} Lv.{expandedCard.level} -

- )} - {expandedCard.link_val != null && ( -

- Link {expandedCard.link_val} -

+
Loading image…
)} +
+ {expandedCard.name} +
+ + {expandedCard.type} + + {expandedCard.attribute && ( + + {expandedCard.attribute} + + )} + {expandedCard.race && ( + + {expandedCard.race} + + )} +
+ {expandedCard.level != null && ( + + {'★'.repeat(Math.min(expandedCard.level, 13))} Lv.{expandedCard.level} + + )} + {expandedCard.link_val != null && ( + Link {expandedCard.link_val} + )} +
) : ( -

Click a card to see details

+ Click a card to see details )}
-
+ {/* Footer */} +