);
}
-export default React.memo(PrintingRow);
\ No newline at end of file
+export default React.memo(PrintingRow);
diff --git a/src/components/SearchBar/SearchBar.jsx b/src/components/SearchBar/SearchBar.jsx
index 5838bb0..e6d3d99 100644
--- a/src/components/SearchBar/SearchBar.jsx
+++ b/src/components/SearchBar/SearchBar.jsx
@@ -2,39 +2,38 @@ import React from 'react';
function SearchBar({ searchTerm, setSearchTerm }) {
return (
-
+
setSearchTerm(e.target.value)}
style={{
width: '100%',
- padding: '0.5rem 2rem 0.5rem 0.5rem',
- borderRadius: '4px',
- border: '1px solid #ccc',
- boxSizing: 'border-box'
+ padding: '8px 32px 8px 12px',
+ background: '#1c1c1c',
+ border: '1px solid #2a2a2a',
+ borderRadius: '6px',
+ color: '#e0e0e0',
+ fontSize: '13px',
+ outline: 'none',
+ boxSizing: 'border-box',
}}
+ onFocus={e => e.target.style.borderColor = '#444'}
+ onBlur={e => e.target.style.borderColor = '#2a2a2a'}
/>
{searchTerm && (
setSearchTerm('')}
style={{
- position: 'absolute',
- right: '0.5rem',
- top: '50%',
- transform: 'translateY(-50%)',
- cursor: 'pointer',
- fontSize: '1rem',
- color: '#888',
- userSelect: 'none'
+ position: 'absolute', right: '10px', top: '50%',
+ transform: 'translateY(-50%)', cursor: 'pointer',
+ fontSize: '16px', color: '#555', userSelect: 'none', lineHeight: 1,
}}
- >
- ×
-
+ >×
)}
);
}
-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] ? (
-
+
) : (
-
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
)}
-