Add Sets page, React Router navigation, collection export, and README
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
@@ -1,18 +1,50 @@
|
||||
# React + Vite
|
||||
# YuGiOh Collection Manager — Frontend
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
React frontend for browsing and managing a personal YuGiOh card collection.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
## Tech Stack
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
- **React 19** + Vite
|
||||
- **react-virtuoso** — virtualized card list (handles 13k+ cards)
|
||||
- **react-router-dom** — client-side routing
|
||||
|
||||
## React Compiler
|
||||
## Pages
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
| Route | Description |
|
||||
|---|---|
|
||||
| `/` | Card list with search, filters, owned count tracking |
|
||||
| `/sets` | Browse sets, view cards per set with owned counts |
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
## Features
|
||||
|
||||
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
||||
- Search by name with punctuation normalization and typo tolerance
|
||||
- Filter by type (Monster / Spell / Trap), owned-only toggle, sort by name or owned count
|
||||
- Per-printing owned count with +/− controls
|
||||
- Card detail panel with artwork switcher (multiple artworks per card)
|
||||
- Collection export as JSON backup
|
||||
- Mobile layout with swipeable bottom sheet for card/set detail
|
||||
- Full DB import via YGOPRODeck API
|
||||
|
||||
## Start Frontend: npm run dev
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Requires the API running at `http://localhost:3000` (proxied via `/api`).
|
||||
|
||||
## Deployment
|
||||
|
||||
Built as a Docker image served by Nginx. CI/CD via Woodpecker on push to `main`.
|
||||
|
||||
```bash
|
||||
docker build -t yugioh-frontend .
|
||||
docker run -d --name yugioh-frontend --network yugioh -p 8041:80 yugioh-frontend
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
| Variable | Description |
|
||||
|---|---|
|
||||
| `VITE_API_URL` | API base URL for dev proxy (default: `http://localhost:3000`) |
|
||||
|
||||
Generated
+59
-12
@@ -10,8 +10,8 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-virtuoso": "^4.18.3",
|
||||
"react-window": "^2.2.7"
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react-virtuoso": "^4.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.4",
|
||||
@@ -1138,6 +1138,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -2255,6 +2268,44 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
"set-cookie-parser": "^2.6.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/react-virtuoso": {
|
||||
"version": "4.18.3",
|
||||
"resolved": "https://registry.npmjs.org/react-virtuoso/-/react-virtuoso-4.18.3.tgz",
|
||||
@@ -2265,16 +2316,6 @@
|
||||
"react-dom": ">=16 || >=17 || >= 18 || >=19"
|
||||
}
|
||||
},
|
||||
"node_modules/react-window": {
|
||||
"version": "2.2.7",
|
||||
"resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz",
|
||||
"integrity": "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-from": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -2342,6 +2383,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-cookie-parser": {
|
||||
"version": "2.7.2",
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"react-virtuoso": "^4.18.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+14
-1
@@ -1,8 +1,21 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||
import NavBar from './components/NavBar/NavBar';
|
||||
import HomePage from './pages/HomePage';
|
||||
import SetsPage from './pages/SetsPage';
|
||||
|
||||
function App() {
|
||||
return <HomePage />;
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<NavBar />
|
||||
<Routes>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/sets" element={<SetsPage />} />
|
||||
</Routes>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
import { NavLink } from 'react-router-dom';
|
||||
|
||||
function NavBar() {
|
||||
return (
|
||||
<nav style={{
|
||||
display: 'flex', alignItems: 'center', gap: '4px',
|
||||
padding: '0 16px', background: '#111', borderBottom: '1px solid #2a2a2a',
|
||||
flexShrink: 0,
|
||||
}}>
|
||||
{[
|
||||
{ to: '/', label: 'Cards' },
|
||||
{ to: '/sets', label: 'Sets' },
|
||||
].map(({ to, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
style={({ isActive }) => ({
|
||||
padding: '10px 16px',
|
||||
fontSize: '13px',
|
||||
color: isActive ? '#e0e0e0' : '#555',
|
||||
textDecoration: 'none',
|
||||
borderBottom: isActive ? '2px solid #e0e0e0' : '2px solid transparent',
|
||||
transition: 'color 0.15s',
|
||||
})}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export default NavBar;
|
||||
+24
-1
@@ -38,6 +38,25 @@ function HomePage() {
|
||||
const [sortBy, setSortBy] = useState('name');
|
||||
|
||||
const { expandedCardId, setExpandedCardId, cardImages, setCardImage, ownedAmounts } = useContext(CardContext);
|
||||
|
||||
const exportCollection = useCallback(() => {
|
||||
const data = cards.flatMap(card =>
|
||||
(card.printings ?? [])
|
||||
.map(p => {
|
||||
const key = `${p.set_id}-${p.rarity_id}`;
|
||||
const amount = ownedAmounts[card.id]?.[key] ?? p.amount_owned ?? 0;
|
||||
return amount > 0 ? { card_id: card.id, card_name: card.name, set_id: p.set_id, set_name: p.set_name, set_code: p.set_code, rarity_id: p.rarity_id, rarity_name: p.rarity_name, amount_owned: amount } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
);
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `yugioh-collection-${new Date().toISOString().slice(0, 10)}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}, [cards, ownedAmounts]);
|
||||
const [artworkIndex, setArtworkIndex] = useState(0);
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const sheetRef = React.useRef(null);
|
||||
@@ -210,7 +229,11 @@ function HomePage() {
|
||||
<span><strong style={{ color: '#e0e0e0' }}>{stats.totalCopies.toLocaleString()}</strong> total copies</span>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<button
|
||||
onClick={exportCollection}
|
||||
style={{ background: '#2a2a2a', color: '#ccc', border: '1px solid #3a3a3a', borderRadius: '5px', padding: '3px 10px', fontSize: '12px', cursor: 'pointer' }}
|
||||
>Export</button>
|
||||
<Footer onImportComplete={loadCards} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import React, { useEffect, useState, useMemo, useContext } from 'react';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { fetchSets, fetchSetCards, updateCardAmount } from '../services/api';
|
||||
import { CardContext } from '../context/CardContext';
|
||||
import { useMediaQuery } from '../hooks/useMediaQuery';
|
||||
import { fuzzyMatch } from '../utils/search';
|
||||
import SearchBar from '../components/SearchBar/SearchBar';
|
||||
|
||||
function SetRow({ set, isSelected, onClick }) {
|
||||
const pct = set.total_in_db > 0 ? Math.round((set.owned_count / set.total_in_db) * 100) : 0;
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
style={{
|
||||
padding: '10px 16px', borderBottom: '1px solid #1e1e1e', cursor: 'pointer',
|
||||
background: isSelected ? '#1c1c1c' : 'transparent',
|
||||
display: 'flex', alignItems: 'center', gap: '12px',
|
||||
}}
|
||||
className="card-row-header"
|
||||
>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px', color: '#d8d8d8', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||
{set.set_name}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: '#444', marginTop: '2px' }}>
|
||||
{set.set_code}{set.tcg_date ? ` · ${set.tcg_date.slice(0, 4)}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: '12px', color: set.owned_count > 0 ? '#aaa' : '#333' }}>
|
||||
{set.owned_count > 0 ? `${set.owned_count}/${set.total_in_db}` : `${set.total_in_db}`}
|
||||
</div>
|
||||
{set.owned_count > 0 && (
|
||||
<div style={{ fontSize: '10px', color: '#555', marginTop: '2px' }}>{pct}%</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetCardRow({ card, zebra }) {
|
||||
const { ownedAmounts, updateAmount } = useContext(CardContext);
|
||||
const key = `${card.set_id ?? ''}-${card.rarity_id}`;
|
||||
const current = ownedAmounts[card.id]?.[key] ?? card.amount_owned ?? 0;
|
||||
|
||||
const save = async (next) => {
|
||||
try {
|
||||
await updateCardAmount(card.id, card.set_id, card.rarity_id, next);
|
||||
updateAmount(card.id, key, next);
|
||||
} catch (err) {
|
||||
console.error('Failed to update amount', err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 120px 90px',
|
||||
gap: '8px', padding: '8px 16px', alignItems: 'center',
|
||||
background: zebra ? '#1e1e1e' : '#161616', borderBottom: '1px solid #1a1a1a',
|
||||
}}>
|
||||
<span style={{ fontSize: '13px', color: '#d8d8d8' }}>{card.name}</span>
|
||||
<span style={{ fontSize: '12px', color: '#777' }}>{card.rarity_name}</span>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px', justifyContent: 'flex-end' }}>
|
||||
<button className="icon-btn" onClick={() => current > 0 && save(current - 1)} disabled={current === 0}>−</button>
|
||||
<span style={{ minWidth: '20px', textAlign: 'center', color: '#e0e0e0', fontSize: '13px' }}>{current}</span>
|
||||
<button className="icon-btn" onClick={() => save(current + 1)}>+</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SetsPage() {
|
||||
const [sets, setSets] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedSet, setSelectedSet] = useState(null);
|
||||
const [setCards, setSetCards] = useState([]);
|
||||
const [cardsLoading, setCardsLoading] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSets().then(setSets).catch(console.error).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const selectSet = (set) => {
|
||||
if (selectedSet?.id === set.id) { setSelectedSet(null); setSetCards([]); return; }
|
||||
setSelectedSet(set);
|
||||
setSetCards([]);
|
||||
setCardsLoading(true);
|
||||
fetchSetCards(set.id).then(setSetCards).catch(console.error).finally(() => setCardsLoading(false));
|
||||
};
|
||||
|
||||
const filteredSets = useMemo(() => {
|
||||
if (!searchTerm) return sets;
|
||||
return sets.filter(s => fuzzyMatch(s.set_name, searchTerm) || fuzzyMatch(s.set_code, searchTerm));
|
||||
}, [sets, searchTerm]);
|
||||
|
||||
const setDetail = selectedSet && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ padding: '12px 16px', borderBottom: '1px solid #2a2a2a', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#e0e0e0' }}>{selectedSet.set_name}</div>
|
||||
<div style={{ fontSize: '11px', color: '#555', marginTop: '2px' }}>
|
||||
{selectedSet.set_code}{selectedSet.tcg_date ? ` · ${selectedSet.tcg_date.slice(0, 4)}` : ''} · {selectedSet.total_in_db} cards
|
||||
{selectedSet.owned_count > 0 && ` · ${selectedSet.owned_count} owned`}
|
||||
</div>
|
||||
</div>
|
||||
{cardsLoading
|
||||
? <p style={{ padding: '1rem', color: '#555', fontSize: '13px' }}>Loading…</p>
|
||||
: (
|
||||
<div style={{ flex: 1, overflowY: 'auto' }}>
|
||||
<div style={{
|
||||
display: 'grid', gridTemplateColumns: '1fr 120px 90px',
|
||||
gap: '8px', padding: '5px 16px',
|
||||
fontSize: '10px', color: '#444', textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid #222',
|
||||
}}>
|
||||
<span>Card</span><span>Rarity</span><span style={{ textAlign: 'right' }}>Owned</span>
|
||||
</div>
|
||||
{setCards.map((card, i) => (
|
||||
<SetCardRow key={`${card.id}-${card.rarity_id}`} card={card} zebra={i % 2 === 1} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) return <p style={{ padding: '1rem', color: '#666' }}>Loading sets…</p>;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||
{/* Sets list */}
|
||||
<div style={{ width: isMobile ? '100%' : '340px', display: 'flex', flexDirection: 'column', borderRight: isMobile ? 'none' : '1px solid #2a2a2a', overflow: 'hidden', flexShrink: 0 }}>
|
||||
<div style={{ padding: '10px 16px', borderBottom: '1px solid #2a2a2a', flexShrink: 0 }}>
|
||||
<SearchBar searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
|
||||
</div>
|
||||
<div style={{ padding: '4px 16px', fontSize: '11px', color: '#444', flexShrink: 0, borderBottom: '1px solid #1a1a1a' }}>
|
||||
{filteredSets.length.toLocaleString()} sets
|
||||
</div>
|
||||
<Virtuoso
|
||||
style={{ flex: 1 }}
|
||||
data={filteredSets}
|
||||
itemContent={(_, set) => (
|
||||
<SetRow set={set} isSelected={selectedSet?.id === set.id} onClick={() => selectSet(set)} />
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Desktop: set detail */}
|
||||
{!isMobile && (
|
||||
<div style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
{setDetail ?? <span style={{ color: '#333', fontSize: '13px', margin: '2rem auto' }}>Select a set</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile: bottom sheet */}
|
||||
{isMobile && selectedSet && (
|
||||
<>
|
||||
<div className="sheet-backdrop" onClick={() => { setSelectedSet(null); setSetCards([]); }} />
|
||||
<div className="bottom-sheet">
|
||||
<div className="sheet-handle-area"><div className="sheet-handle" /></div>
|
||||
{setDetail}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SetsPage;
|
||||
+12
-1
@@ -1,4 +1,3 @@
|
||||
//api.jsx
|
||||
const API_BASE = '/api';
|
||||
|
||||
export async function fetchCards() {
|
||||
@@ -45,3 +44,15 @@ export async function triggerFullImport() {
|
||||
if (!response.ok) throw new Error('Failed to trigger full import');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchSets() {
|
||||
const response = await fetch(`${API_BASE}/sets`);
|
||||
if (!response.ok) throw new Error('Failed to fetch sets');
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
export async function fetchSetCards(setId) {
|
||||
const response = await fetch(`${API_BASE}/sets/${setId}/cards`);
|
||||
if (!response.ok) throw new Error('Failed to fetch set cards');
|
||||
return await response.json();
|
||||
}
|
||||
Reference in New Issue
Block a user