diff --git a/README.md b/README.md index e806706..ef507d3 100644 --- a/README.md +++ b/README.md @@ -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`) | diff --git a/package-lock.json b/package-lock.json index 8ef650b..0034e8b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 9be697a..28553ab 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/src/App.jsx b/src/App.jsx index 5e68296..6169d0d 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 ; + return ( + +
+ + + } /> + } /> + +
+
+ ); } export default App; diff --git a/src/components/NavBar/NavBar.jsx b/src/components/NavBar/NavBar.jsx new file mode 100644 index 0000000..1cb0ef2 --- /dev/null +++ b/src/components/NavBar/NavBar.jsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; + +function NavBar() { + return ( + + ); +} + +export default NavBar; diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 600231a..896d4cb 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -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() { {stats.totalCopies.toLocaleString()} total copies )} -
+
+
diff --git a/src/pages/SetsPage.jsx b/src/pages/SetsPage.jsx new file mode 100644 index 0000000..14ca28f --- /dev/null +++ b/src/pages/SetsPage.jsx @@ -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 ( +
+
+
+ {set.set_name} +
+
+ {set.set_code}{set.tcg_date ? ` · ${set.tcg_date.slice(0, 4)}` : ''} +
+
+
+
0 ? '#aaa' : '#333' }}> + {set.owned_count > 0 ? `${set.owned_count}/${set.total_in_db}` : `${set.total_in_db}`} +
+ {set.owned_count > 0 && ( +
{pct}%
+ )} +
+
+ ); +} + +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 ( +
+ {card.name} + {card.rarity_name} +
+ + {current} + +
+
+ ); +} + +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 && ( +
+
+
{selectedSet.set_name}
+
+ {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`} +
+
+ {cardsLoading + ?

Loading…

+ : ( +
+
+ CardRarityOwned +
+ {setCards.map((card, i) => ( + + ))} +
+ ) + } +
+ ); + + if (loading) return

Loading sets…

; + + return ( +
+ {/* Sets list */} +
+
+ +
+
+ {filteredSets.length.toLocaleString()} sets +
+ ( + selectSet(set)} /> + )} + /> +
+ + {/* Desktop: set detail */} + {!isMobile && ( +
+ {setDetail ?? Select a set} +
+ )} + + {/* Mobile: bottom sheet */} + {isMobile && selectedSet && ( + <> +
{ setSelectedSet(null); setSetCards([]); }} /> +
+
+ {setDetail} +
+ + )} +
+ ); +} + +export default SetsPage; diff --git a/src/services/api.js b/src/services/api.js index 3ee20a0..3309ecf 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -1,4 +1,3 @@ -//api.jsx const API_BASE = '/api'; export async function fetchCards() { @@ -44,4 +43,16 @@ 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(); } \ No newline at end of file