From 9106c8b87392d41ac0d946294af79faa4f5099c0 Mon Sep 17 00:00:00 2001 From: gpatruno Date: Thu, 16 Jul 2026 20:23:58 +0200 Subject: [PATCH] nice update --- .gitignore | 5 +- README.md | 42 +- client/package-lock.json | 111 +- client/package.json | 3 +- client/src/App.jsx | 16 + client/src/AuthContext.jsx | 16 +- client/src/api.js | 68 +- client/src/components/CaseReel.jsx | 22 +- client/src/components/DropFeed.jsx | 127 ++ client/src/components/ItemTile.jsx | 39 +- client/src/components/Layout.jsx | 54 +- client/src/hooks/useCountdown.js | 24 + client/src/index.css | 608 +++++++++ client/src/pages/Admin.jsx | 472 +++++-- client/src/pages/BetHub.jsx | 58 + client/src/pages/CasePage.jsx | 11 +- client/src/pages/CasesPage.jsx | 55 + client/src/pages/CoinflipStub.jsx | 35 + client/src/pages/Dashboard.jsx | 139 +- client/src/pages/DuelListPage.jsx | 182 +++ client/src/pages/DuelRoomPage.jsx | 334 +++++ client/src/pages/MultiPlayersPage.jsx | 315 +++++ client/src/pages/ProfilePage.jsx | 199 +++ client/src/pages/ShopPage.jsx | 189 +++ client/src/socket.js | 17 + client/vite.config.js | 10 + package-lock.json | 253 ++-- server/package-lock.json | 1713 +++++-------------------- server/package.json | 4 +- server/prisma/schema.prisma | 90 +- server/prisma/seed.js | 12 +- server/src/feed.js | 22 + server/src/index.js | 165 ++- server/src/middleware.js | 4 + server/src/realtime.js | 24 + server/src/routes/admin.js | 14 + server/src/routes/auth.js | 11 + server/src/routes/cases.js | 12 +- server/src/routes/duels.js | 89 ++ server/src/routes/feed.js | 10 + server/src/routes/inventory.js | 67 +- server/src/routes/jackpot.js | 44 + server/src/routes/profile.js | 148 +++ server/src/routes/shop.js | 164 +++ server/src/services/duel.js | 455 +++++++ server/src/services/jackpot.js | 430 +++++++ server/src/upload.js | 33 + 47 files changed, 5183 insertions(+), 1732 deletions(-) create mode 100644 client/src/components/DropFeed.jsx create mode 100644 client/src/hooks/useCountdown.js create mode 100644 client/src/pages/BetHub.jsx create mode 100644 client/src/pages/CasesPage.jsx create mode 100644 client/src/pages/CoinflipStub.jsx create mode 100644 client/src/pages/DuelListPage.jsx create mode 100644 client/src/pages/DuelRoomPage.jsx create mode 100644 client/src/pages/MultiPlayersPage.jsx create mode 100644 client/src/pages/ProfilePage.jsx create mode 100644 client/src/pages/ShopPage.jsx create mode 100644 client/src/socket.js create mode 100644 server/src/feed.js create mode 100644 server/src/realtime.js create mode 100644 server/src/routes/duels.js create mode 100644 server/src/routes/feed.js create mode 100644 server/src/routes/jackpot.js create mode 100644 server/src/routes/profile.js create mode 100644 server/src/routes/shop.js create mode 100644 server/src/services/duel.js create mode 100644 server/src/services/jackpot.js create mode 100644 server/src/upload.js diff --git a/.gitignore b/.gitignore index 8965cf3..9389485 100644 --- a/.gitignore +++ b/.gitignore @@ -4,5 +4,6 @@ dist/ *.db *.db-journal .DS_Store -server/sessions.db -client/dist/ +server/uploads/ +!server/uploads/.gitkeep + diff --git a/README.md b/README.md index 2df591c..07f4ada 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # CaseForge -CS-style case opening site with virtual credits, inventory, leaderboard, and an admin panel for cases / drops / rates. +CS-style case opening site with virtual credits, inventory, multiplayer item betting, shop, leaderboard, and an admin panel for cases / drops / rates. ## Stack -- **Server**: Node.js, Express, Prisma, SQLite, express-session -- **Client**: React (Vite), React Router +- **Server**: Node.js, Express, Prisma, SQLite, express-session, Socket.IO +- **Client**: React (Vite), React Router, Socket.IO client ## Setup @@ -18,6 +18,20 @@ npm run dev - App: http://localhost:5173 - API: http://localhost:3001 +- LAN: http://\:5173 (Vite binds to `0.0.0.0`) + +Create `server/.env` if missing: + +``` +DATABASE_URL="file:./dev.db" +PORT=3001 +CLIENT_ORIGIN=http://localhost:5173 +SESSION_SECRET=dev-secret +STARTING_BALANCE=10000 +STARTING_OSU=100 +JACKPOT_ROUND_SECONDS=60 +JACKPOT_MAX_ITEMS=3 +``` ## Demo accounts @@ -26,18 +40,28 @@ npm run dev | Admin | `admin` | `admin123` | | Player| `player` | `player123`| -New registrations receive **100.00** starting credits (`STARTING_BALANCE` in `server/.env`, stored in cents). +New registrations receive **100.00** starting credits and **100 osu** (`STARTING_BALANCE` / `STARTING_OSU` in `server/.env`; credits stored in cents). ## Features - Login / register (username + password) -- Dashboard: inventory + available cases -- Case page with CS-style reel open animation (result rolled server-side) +- Inventory: sort by recent or value, multi-select and sell at market value +- **Feed Drop**: toggle in nav — live left column of latest case unboxes + player +- Cases page: browse and open crates (CS-style reel, result rolled server-side) +- **Bet Item** hub: + - **MultiPlayers Case** — live shared pot, up to 3 items, win chance by value, Socket.IO sync + - **1vX Case** — custom rooms (player/item limits, min value), spectate in-progress + - **Coinflip** — stub (coming soon) +- **Shop**: spend osu on credit packs; watch an ad every 1 min for 100.00–200.00 cr +- Profile (via username in the nav): avatar, bio, password, stats - Leaderboard: richest players (balance + inventory market value) -- Admin: manage cases, items, drop weights, enable/disable cases +- Admin: manage cases, items, images (URL or upload), drop weights, enable/disable cases ## Notes -- Balances and prices are integers in **cents** -- `Transaction` types include `open_case` and `seed_credit` (stubs for future `deposit` / `withdrawal`) +- Balances and prices are integers in **cents**; **osu** is a separate demo currency for the shop +- Inventory items locked while deposited in a bet; unlocked or transferred on resolve +- `Transaction` types include `open_case`, `seed_credit`, `seed_osu`, `sell_item`, `shop_purchase`, `ad_reward`, `jackpot_bet`, `jackpot_win`, `duel_bet`, `duel_win` +- Avatars stored under `server/uploads/` and served at `/uploads` +- Real-time state via Socket.IO (`jackpot`, `duel:{id}`, `duels` rooms) - Swap `DATABASE_URL` to PostgreSQL later without changing app logic diff --git a/client/package-lock.json b/client/package-lock.json index 4c99adb..a6407fe 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -10,7 +10,8 @@ "dependencies": { "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.18.1" + "react-router-dom": "^7.18.1", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@types/react": "^19.2.17", @@ -670,6 +671,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.3", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", @@ -747,6 +754,23 @@ "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -757,6 +781,28 @@ "node": ">=8" } }, + "node_modules/engine.io-client": { + "version": "6.6.6", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.6.tgz", + "integrity": "sha512-iY6QdftLQ9pyiPoX082bpf/u1UewnOaJrtJIF9T0++QB34lZrj0uP+Q/bj8AlUsAxqhnkTV2BS8SBZSxOmoV5Q==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.21.0", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1051,6 +1097,12 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.16", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", @@ -1273,6 +1325,34 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1385,6 +1465,35 @@ "optional": true } } + }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } } } } diff --git a/client/package.json b/client/package.json index feeb187..499e24c 100644 --- a/client/package.json +++ b/client/package.json @@ -12,7 +12,8 @@ "dependencies": { "react": "^19.2.7", "react-dom": "^19.2.7", - "react-router-dom": "^7.18.1" + "react-router-dom": "^7.18.1", + "socket.io-client": "^4.8.3" }, "devDependencies": { "@types/react": "^19.2.17", diff --git a/client/src/App.jsx b/client/src/App.jsx index 7983f11..e91e451 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -3,10 +3,18 @@ import Layout from './components/Layout'; import { useAuth } from './AuthContext'; import Admin from './pages/Admin'; import CasePage from './pages/CasePage'; +import CasesPage from './pages/CasesPage'; import Dashboard from './pages/Dashboard'; import Leaderboard from './pages/Leaderboard'; import Login from './pages/Login'; import Register from './pages/Register'; +import BetHub from './pages/BetHub'; +import MultiPlayersPage from './pages/MultiPlayersPage'; +import DuelListPage from './pages/DuelListPage'; +import DuelRoomPage from './pages/DuelRoomPage'; +import CoinflipStub from './pages/CoinflipStub'; +import ProfilePage from './pages/ProfilePage'; +import ShopPage from './pages/ShopPage'; function HomeRedirect() { const { user, loading } = useAuth(); @@ -25,7 +33,15 @@ export default function App() { }> } /> } /> + } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/client/src/AuthContext.jsx b/client/src/AuthContext.jsx index 33aee34..cc46a6b 100644 --- a/client/src/AuthContext.jsx +++ b/client/src/AuthContext.jsx @@ -49,9 +49,23 @@ export function AuthProvider({ children }) { setUser((prev) => (prev ? { ...prev, balance } : prev)); }; + const patchUser = (partial) => { + setUser((prev) => (prev ? { ...prev, ...partial } : prev)); + }; + return ( {children} diff --git a/client/src/api.js b/client/src/api.js index a63463c..e32b8d4 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -1,11 +1,13 @@ async function request(path, options = {}) { + const headers = { ...(options.headers || {}) }; + if (!(options.body instanceof FormData)) { + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + } + const res = await fetch(path, { credentials: 'include', - headers: { - 'Content-Type': 'application/json', - ...(options.headers || {}), - }, ...options, + headers, }); const data = await res.json().catch(() => ({})); @@ -32,7 +34,56 @@ export const api = { case: (id) => request(`/api/cases/${id}`), openCase: (id) => request(`/api/cases/${id}/open`, { method: 'POST' }), inventory: () => request('/api/inventory'), + sellInventory: (inventoryItemIds) => + request('/api/inventory/sell', { + method: 'POST', + body: JSON.stringify({ inventoryItemIds }), + }), + shop: () => request('/api/shop'), + shopBuy: (packageId) => + request('/api/shop/buy', { + method: 'POST', + body: JSON.stringify({ packageId }), + }), + shopAd: () => request('/api/shop/ad', { method: 'POST' }), + feed: () => request('/api/feed'), leaderboard: () => request('/api/leaderboard'), + jackpot: () => request('/api/jackpot'), + jackpotBet: (inventoryItemIds) => + request('/api/jackpot/bet', { + method: 'POST', + body: JSON.stringify({ inventoryItemIds }), + }), + jackpotWithdraw: (inventoryItemIds) => + request('/api/jackpot/withdraw', { + method: 'POST', + body: JSON.stringify({ inventoryItemIds }), + }), + duels: () => request('/api/duels'), + duel: (id) => request(`/api/duels/${id}`), + createDuel: (body) => + request('/api/duels', { method: 'POST', body: JSON.stringify(body) }), + duelBet: (id, inventoryItemIds) => + request(`/api/duels/${id}/bet`, { + method: 'POST', + body: JSON.stringify({ inventoryItemIds }), + }), + duelLeave: (id) => request(`/api/duels/${id}/leave`, { method: 'POST' }), + duelCancel: (id) => request(`/api/duels/${id}/cancel`, { method: 'POST' }), + duelStart: (id) => request(`/api/duels/${id}/start`, { method: 'POST' }), + profile: () => request('/api/profile'), + updateProfile: (body) => + request('/api/profile', { method: 'PATCH', body: JSON.stringify(body) }), + changePassword: (currentPassword, newPassword) => + request('/api/profile/password', { + method: 'POST', + body: JSON.stringify({ currentPassword, newPassword }), + }), + uploadAvatar: (file) => { + const form = new FormData(); + form.append('avatar', file); + return request('/api/profile/avatar', { method: 'POST', body: form }); + }, adminLogin: (username, password) => request('/api/admin/login', { method: 'POST', @@ -63,8 +114,17 @@ export const api = { }), deleteCaseItem: (id) => request(`/api/admin/case-items/${id}`, { method: 'DELETE' }), + adminUpload: (file) => { + const form = new FormData(); + form.append('image', file); + return request('/api/admin/upload', { method: 'POST', body: form }); + }, }; export function formatCredits(cents) { return (Number(cents) / 100).toFixed(2); } + +export function formatChance(pct) { + return `${Number(pct || 0).toFixed(1)}%`; +} diff --git a/client/src/components/CaseReel.jsx b/client/src/components/CaseReel.jsx index 0a8deef..8ac67ce 100644 --- a/client/src/components/CaseReel.jsx +++ b/client/src/components/CaseReel.jsx @@ -18,7 +18,7 @@ function buildTrack(items, winnerId, spins = 42) { return { track, winnerIndex: spins }; } -export default function CaseReel({ items, winnerId, spinning, onDone }) { +export default function CaseReel({ items, winnerId, spinning, onDone, showOwners = false }) { const trackRef = useRef(null); const [offset, setOffset] = useState(0); const onDoneRef = useRef(onDone); @@ -55,7 +55,7 @@ export default function CaseReel({ items, winnerId, spinning, onDone }) { }, [spinning, winnerId, winnerIndex, items]); return ( -
+
- {item.name.slice(0, 2).toUpperCase()} + {item.imageUrl ? ( + + ) : ( + item.name.slice(0, 2).toUpperCase() + )}
{item.name}
+ {showOwners && (item.ownerUsername || item.ownerAvatarUrl) ? ( +
+ {item.ownerAvatarUrl ? ( + + ) : ( + + {(item.ownerUsername || '?').slice(0, 1).toUpperCase()} + + )} + {item.ownerUsername} +
+ ) : null}
))}
diff --git a/client/src/components/DropFeed.jsx b/client/src/components/DropFeed.jsx new file mode 100644 index 0000000..44cc683 --- /dev/null +++ b/client/src/components/DropFeed.jsx @@ -0,0 +1,127 @@ +import { useEffect, useMemo, useState } from 'react'; +import { api, formatCredits } from '../api'; +import { getSocket } from '../socket'; +import { rarityColor } from '../rarities'; + +const MAX_BUFFER = 40; +const KEEP_FOREVER = 5; +const EXPIRE_MS = 3 * 60 * 1000; // older than 3 min disappear (except top 5) + +function formatDropTime(iso) { + if (!iso) return ''; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ''; + return d.toLocaleString(undefined, { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); +} + +function pruneDrops(list, now = Date.now()) { + return list.filter((d, index) => { + if (index < KEEP_FOREVER) return true; + const t = new Date(d.at).getTime(); + if (Number.isNaN(t)) return false; + return now - t < EXPIRE_MS; + }); +} + +export default function DropFeed() { + const [drops, setDrops] = useState([]); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + let cancelled = false; + + (async () => { + try { + const data = await api.feed(); + if (!cancelled) setDrops(pruneDrops(data.drops || [])); + } catch { + /* ignore — socket history may fill */ + } + })(); + + const socket = getSocket(); + const onHistory = (list) => { + setDrops(pruneDrops(Array.isArray(list) ? list.slice(0, MAX_BUFFER) : [])); + }; + const onDrop = (entry) => { + setDrops((prev) => pruneDrops([entry, ...prev].slice(0, MAX_BUFFER))); + }; + + socket.emit('feed:subscribe'); + socket.on('feed:history', onHistory); + socket.on('feed:drop', onDrop); + socket.on('connect', () => socket.emit('feed:subscribe')); + + return () => { + cancelled = true; + socket.emit('feed:unsubscribe'); + socket.off('feed:history', onHistory); + socket.off('feed:drop', onDrop); + }; + }, []); + + useEffect(() => { + const id = setInterval(() => { + const t = Date.now(); + setNow(t); + setDrops((prev) => pruneDrops(prev, t)); + }, 1000); + return () => clearInterval(id); + }, []); + + const visible = useMemo(() => pruneDrops(drops, now), [drops, now]); + + return ( + + ); +} diff --git a/client/src/components/ItemTile.jsx b/client/src/components/ItemTile.jsx index 7c5c3ef..b196bdb 100644 --- a/client/src/components/ItemTile.jsx +++ b/client/src/components/ItemTile.jsx @@ -1,12 +1,43 @@ import { formatCredits } from '../api'; import { rarityColor } from '../rarities'; -export default function ItemTile({ item, showValue = true }) { +export default function ItemTile({ + item, + showValue = true, + selected = false, + onClick, + disabled = false, +}) { const color = rarityColor(item.rarity); + const clickable = typeof onClick === 'function'; + const hasImage = Boolean(item.imageUrl); + return ( -
-
- {item.name.slice(0, 2).toUpperCase()} +
{ + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick(); + } + } + : undefined + } + > +
+ {hasImage ? ( + + ) : ( + item.name.slice(0, 2).toUpperCase() + )}
{item.name}
diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index cbcdab3..edabd69 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -1,18 +1,52 @@ +import { useEffect, useState } from 'react'; import { NavLink, Outlet } from 'react-router-dom'; import { useAuth } from '../AuthContext'; import { formatCredits } from '../api'; +import DropFeed from './DropFeed'; + +const FEED_KEY = 'caseforge-feed-drop'; export default function Layout() { const { user, logout } = useAuth(); + const [feedOpen, setFeedOpen] = useState(() => { + try { + return localStorage.getItem(FEED_KEY) === '1'; + } catch { + return false; + } + }); + + useEffect(() => { + try { + localStorage.setItem(FEED_KEY, feedOpen ? '1' : '0'); + } catch { + /* ignore */ + } + }, [feedOpen]); return ( -
+
CaseForge
-
- -
+
+ {feedOpen && user && user.role !== 'admin' && } +
+ +
+
); } diff --git a/client/src/hooks/useCountdown.js b/client/src/hooks/useCountdown.js new file mode 100644 index 0000000..e6f493f --- /dev/null +++ b/client/src/hooks/useCountdown.js @@ -0,0 +1,24 @@ +import { useEffect, useState } from 'react'; + +export function useCountdown(endsAt) { + const [remaining, setRemaining] = useState(0); + + useEffect(() => { + if (!endsAt) { + setRemaining(0); + return undefined; + } + + const tick = () => { + const ms = new Date(endsAt).getTime() - Date.now(); + setRemaining(Math.max(0, Math.ceil(ms / 1000))); + }; + tick(); + const id = setInterval(tick, 250); + return () => clearInterval(id); + }, [endsAt]); + + const mm = String(Math.floor(remaining / 60)).padStart(2, '0'); + const ss = String(remaining % 60).padStart(2, '0'); + return { remaining, label: `${mm}:${ss}` }; +} diff --git a/client/src/index.css b/client/src/index.css index f112a3e..b369c81 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -65,6 +65,18 @@ h3 { flex-direction: column; } +.app-body { + display: flex; + flex: 1; + min-height: 0; + width: 100%; +} + +.app-body .main { + flex: 1; + min-width: 0; +} + .topnav { display: flex; align-items: center; @@ -104,6 +116,158 @@ h3 { color: var(--text); } +.nav-toggle { + background: transparent; + border: 1px solid var(--stroke); + color: var(--muted); + font: inherit; + font-weight: 500; + padding: 0.25rem 0.55rem; + cursor: pointer; +} + +.nav-toggle:hover, +.nav-toggle.active { + color: var(--text); + border-color: var(--accent-dim); + background: rgba(61, 214, 198, 0.08); +} + +.drop-feed { + width: 280px; + flex-shrink: 0; + border-right: 1px solid var(--stroke); + background: rgba(10, 15, 19, 0.85); + display: flex; + flex-direction: column; + max-height: calc(100vh - 58px); + position: sticky; + top: 58px; + align-self: flex-start; +} + +.drop-feed-head { + padding: 0.9rem 1rem 0.5rem; + border-bottom: 1px solid var(--stroke); +} + +.drop-feed-head h2 { + font-size: 1rem; + margin: 0; +} + +.drop-feed-list { + overflow-y: auto; + flex: 1; + padding: 0.5rem; + display: flex; + flex-direction: column; + gap: 0.45rem; +} + +.drop-feed-empty { + padding: 1rem 0.5rem; + font-size: 0.85rem; +} + +.drop-feed-row { + display: flex; + gap: 0.55rem; + align-items: center; + padding: 0.45rem; + border: 1px solid var(--stroke); + border-left-width: 3px; + background: var(--bg-panel); + animation: rise-in 280ms ease both; +} + +.drop-feed-item-visual { + width: 44px; + height: 44px; + flex-shrink: 0; + display: grid; + place-items: center; + font-family: var(--font-display); + font-size: 0.85rem; + background: var(--bg-deep); + border: 1px solid var(--stroke); + overflow: hidden; +} + +.drop-feed-item-visual img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.drop-feed-meta { + min-width: 0; + flex: 1; +} + +.drop-feed-item-name { + font-family: var(--font-display); + font-size: 0.85rem; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.drop-feed-player { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + margin-top: 0.15rem; +} + +.drop-feed-avatar { + width: 18px; + height: 18px; + object-fit: cover; + border: 1px solid var(--stroke); +} + +.drop-feed-avatar.fallback { + display: grid; + place-items: center; + font-size: 0.6rem; + background: var(--bg-elevated); + color: var(--accent); + font-family: var(--font-display); +} + +.drop-feed-sub { + font-size: 0.72rem; + margin-top: 0.1rem; +} + +.drop-feed-time { + font-size: 0.68rem; + margin-top: 0.15rem; +} + +@media (max-width: 900px) { + .drop-feed { + width: 220px; + } +} + +@media (max-width: 700px) { + .app-body { + flex-direction: column; + } + + .drop-feed { + width: 100%; + max-height: 220px; + position: static; + border-right: none; + border-bottom: 1px solid var(--stroke); + } +} + .balance-pill { font-family: var(--font-display); color: var(--gold); @@ -343,6 +507,10 @@ h3 { height: 140px; } +.reel-wrap.reel-wrap-owners { + height: 200px; +} + .reel-wrap::before, .reel-wrap::after { content: ''; @@ -412,6 +580,44 @@ h3 { white-space: nowrap; } +.reel-owner { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + max-width: 100%; + margin-top: 0.2rem; +} + +.reel-owner-avatar, +.reel-owner-fallback { + width: 42px; + height: 42px; + flex-shrink: 0; + border: 1px solid var(--stroke); + object-fit: cover; +} + +.reel-owner-fallback { + display: grid; + place-items: center; + font-size: 1rem; + background: var(--bg-elevated); + color: var(--accent); + font-family: var(--font-display); +} + +.reel-owner-name { + font-size: 0.62rem; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100px; + text-align: center; +} + .won-banner { text-align: center; padding: 1rem; @@ -426,6 +632,15 @@ h3 { font-size: 1.3rem; } +.won-img { + width: 96px; + height: 96px; + object-fit: cover; + border: 1px solid var(--stroke); + margin: 0 auto 0.5rem; + display: block; +} + .table { width: 100%; border-collapse: collapse; @@ -542,3 +757,396 @@ h3 { transform: translateY(-6px); } } + +.item-tile.selectable { + cursor: pointer; +} + +.item-tile.selected { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +.item-tile.disabled { + opacity: 0.4; + pointer-events: none; +} + +.mode-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 1rem; +} + +.mode-card { + display: block; + padding: 1.25rem; + border: 1px solid var(--stroke); + background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel)); + min-height: 160px; + transition: transform 180ms ease, border-color 180ms ease; + position: relative; +} + +a.mode-card:hover { + transform: translateY(-3px); + border-color: var(--accent-dim); +} + +.mode-card.stub { + opacity: 0.75; +} + +.badge { + display: inline-block; + font-size: 0.75rem; + font-family: var(--font-display); + color: var(--gold); + border: 1px solid rgba(228, 174, 57, 0.4); + padding: 0.15rem 0.45rem; + margin-top: 0.5rem; +} + +.jackpot-status { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.timer-block { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.85rem 1rem; + border: 1px solid var(--stroke); + background: var(--bg-panel); +} + +.timer-digits { + font-family: var(--font-display); + font-size: 1.6rem; + color: var(--accent); +} + +.gold { + color: var(--gold); + font-family: var(--font-display); +} + +.status-pill { + display: inline-block; + width: fit-content; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--muted); +} + +.status-pill.open { + color: var(--accent); +} + +.status-pill.spinning, +.status-pill.in_progress { + color: var(--gold); +} + +.status-pill.resolved, +.status-pill.cancelled { + color: var(--muted); +} + +.row-me { + background: rgba(61, 214, 198, 0.06); +} + +.deposit-wrap { + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.deposit-owner { + font-size: 0.8rem; + text-align: center; +} + +.row-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.room-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.room-card { + display: block; + padding: 1rem; + border: 1px solid var(--stroke); + background: var(--bg-panel); + transition: border-color 160ms ease; +} + +.room-card:hover { + border-color: var(--accent-dim); +} + +.room-card.live { + border-color: rgba(228, 174, 57, 0.35); +} + +.room-title { + font-family: var(--font-display); + font-weight: 600; + margin-bottom: 0.35rem; +} + +.form-inline { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 0.75rem; + align-items: end; +} + +.profile-layout { + display: grid; + grid-template-columns: 200px 1fr; + gap: 1rem; +} + +@media (max-width: 700px) { + .profile-layout { + grid-template-columns: 1fr; + } +} + +.avatar-preview { + width: 120px; + height: 120px; + border: 1px solid var(--stroke); + background: var(--bg-deep); + display: grid; + place-items: center; + margin-bottom: 0.75rem; + overflow: hidden; + font-family: var(--font-display); + font-size: 2rem; + color: var(--accent); +} + +.avatar-preview img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.file-btn { + display: inline-block; + text-align: center; +} + +.stat-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 0.55rem; +} + +.ok-msg { + color: var(--accent); + font-size: 0.9rem; + margin-bottom: 0.75rem; +} + +.nav-user { + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.nav-avatar { + width: 24px; + height: 24px; + object-fit: cover; + border: 1px solid var(--stroke); +} + +.stub-panel { + text-align: center; +} + +.item-visual.has-img, +.case-visual.has-img, +.case-icon.has-img { + padding: 0; + overflow: hidden; +} + +.item-visual img, +.case-visual img, +.case-icon img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.case-icon.has-img { + padding: 0; +} + +.case-icon img { + border-radius: 0; +} + +.reel-img { + width: 72px; + height: 72px; + object-fit: cover; + display: block; +} + +.reel-wrap-owners .reel-img { + width: 48px; + height: 48px; +} + +.reel-wrap-owners .reel-item .glyph { + font-size: 1.1rem; +} + +.image-field { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.image-field input[type='text'], +.image-field > input:not([type]) { + flex: 1; + min-width: 160px; + background: var(--bg-deep); + border: 1px solid var(--stroke); + color: var(--text); + padding: 0.55rem 0.65rem; +} + +.admin-thumb { + width: 48px; + height: 48px; + border: 1px solid var(--stroke); + background: var(--bg-deep); + overflow: hidden; + flex-shrink: 0; + display: grid; + place-items: center; + font-size: 0.65rem; + color: var(--muted); +} + +.admin-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.admin-thumb.empty { + font-size: 0.65rem; +} + +.checkbox-row { + flex-direction: row !important; + align-items: center; + gap: 0.5rem !important; +} + +.drop-item-cell { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.drop-thumb { + width: 28px; + height: 28px; + object-fit: cover; + border: 1px solid var(--stroke); +} + +.osu-pill { + font-family: var(--font-display); + color: var(--accent); + border: 1px solid rgba(61, 214, 198, 0.35); + background: rgba(61, 214, 198, 0.08); + padding: 0.35rem 0.7rem; +} + +.inv-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; +} + +.sort-toggle { + display: inline-flex; + border: 1px solid var(--stroke); +} + +.sort-toggle button { + background: transparent; + border: none; + color: var(--muted); + padding: 0.45rem 0.85rem; + cursor: pointer; + font-family: var(--font-display); + font-size: 0.85rem; +} + +.sort-toggle button.active { + color: var(--text); + background: rgba(61, 214, 198, 0.1); +} + +.shop-pack .pack-credits { + font-family: var(--font-display); + font-size: 1.4rem; + color: var(--gold); + margin: 0.35rem 0; +} + +.ad-panel { + margin-top: 0.5rem; +} + +.ad-sim { + margin-top: 0.75rem; +} + +.ad-bar { + height: 6px; + border-radius: 0; + background: linear-gradient(90deg, var(--accent-dim), var(--accent), var(--accent-dim)); + background-size: 200% 100%; + animation: ad-slide 1.6s linear infinite; +} + +@keyframes ad-slide { + from { + background-position: 100% 0; + } + to { + background-position: -100% 0; + } +} diff --git a/client/src/pages/Admin.jsx b/client/src/pages/Admin.jsx index 398d61d..7aff016 100644 --- a/client/src/pages/Admin.jsx +++ b/client/src/pages/Admin.jsx @@ -3,6 +3,55 @@ import { Link, useNavigate } from 'react-router-dom'; import { api, formatCredits } from '../api'; import { useAuth } from '../AuthContext'; +const emptyCase = { name: '', price: 100, imageUrl: '', active: true }; +const emptyItem = { name: '', rarity: 'MilSpec', marketValue: 100, imageUrl: '' }; + +function ImageField({ value, onChange, disabled }) { + const [uploading, setUploading] = useState(false); + + const onFile = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setUploading(true); + try { + const data = await api.adminUpload(file); + onChange(data.url); + } catch (err) { + alert(err.message); + } finally { + setUploading(false); + e.target.value = ''; + } + }; + + return ( +
+ {value ? ( +
+ +
+ ) : ( +
No img
+ )} + onChange(e.target.value)} + disabled={disabled} + /> + + {value ? ( + + ) : null} +
+ ); +} + export default function Admin() { const { user, loading, adminLogin, logout } = useAuth(); const navigate = useNavigate(); @@ -15,13 +64,12 @@ export default function Admin() { const [password, setPassword] = useState(''); const [busy, setBusy] = useState(false); - const [caseForm, setCaseForm] = useState({ name: '', price: 100, imageUrl: '', active: true }); - const [itemForm, setItemForm] = useState({ - name: '', - rarity: 'MilSpec', - marketValue: 100, - imageUrl: '', - }); + const [caseForm, setCaseForm] = useState(emptyCase); + const [itemForm, setItemForm] = useState(emptyItem); + const [editingCaseId, setEditingCaseId] = useState(null); + const [editingItemId, setEditingItemId] = useState(null); + const [caseEdit, setCaseEdit] = useState(null); + const [itemEdit, setItemEdit] = useState(null); const [dropForm, setDropForm] = useState({ caseId: '', itemId: '', dropRate: 1 }); const load = async () => { @@ -39,8 +87,8 @@ export default function Admin() { if (!dropForm.itemId && i.items[0]) { setDropForm((f) => ({ ...f, itemId: String(i.items[0].id) })); } - if (r.rarities.length && !itemForm.rarity) { - setItemForm((f) => ({ ...f, rarity: r.rarities[0] })); + if (r.rarities.length) { + setItemForm((f) => ({ ...f, rarity: f.rarity || r.rarities[0] })); } }; @@ -96,6 +144,62 @@ export default function Admin() { ); } + const startEditCase = (c) => { + setEditingCaseId(c.id); + setCaseEdit({ + name: c.name, + price: c.price, + imageUrl: c.imageUrl || '', + active: c.active, + }); + }; + + const saveCaseEdit = async (e) => { + e.preventDefault(); + setError(''); + try { + await api.updateCase(editingCaseId, { + name: caseEdit.name, + price: Number(caseEdit.price), + imageUrl: caseEdit.imageUrl, + active: caseEdit.active, + }); + setEditingCaseId(null); + setCaseEdit(null); + await load(); + } catch (err) { + setError(err.message); + } + }; + + const startEditItem = (item) => { + setEditingItemId(item.id); + setItemEdit({ + name: item.name, + rarity: item.rarity, + marketValue: item.marketValue, + imageUrl: item.imageUrl || '', + }); + }; + + const saveItemEdit = async (e) => { + e.preventDefault(); + setError(''); + try { + await api.updateItem(editingItemId, { + name: itemEdit.name, + rarity: itemEdit.rarity, + marketValue: Number(itemEdit.marketValue), + imageUrl: itemEdit.imageUrl, + }); + setEditingItemId(null); + setItemEdit(null); + await load(); + } catch (err) { + setError(err.message); + } + }; + return (
@@ -117,7 +221,7 @@ export default function Admin() {

Admin panel

-

Cases, loot pool, and drop weights.

+

Cases, loot pool, images, and drop weights.

@@ -141,7 +245,7 @@ export default function Admin() {

Create case

{ e.preventDefault(); try { @@ -149,66 +253,135 @@ export default function Admin() { ...caseForm, price: Number(caseForm.price), }); - setCaseForm({ name: '', price: 100, imageUrl: '', active: true }); + setCaseForm(emptyCase); await load(); } catch (err) { setError(err.message); } }} > - setCaseForm({ ...caseForm, name: e.target.value })} - required - /> - setCaseForm({ ...caseForm, price: e.target.value })} - required - /> - setCaseForm({ ...caseForm, imageUrl: e.target.value })} - /> + + +
{cases.map((c) => (
-
- {c.name} - {formatCredits(c.price)} cr - {c.active ? 'active' : 'inactive'} - - -
-
{c.items.length} drops configured
+ {editingCaseId === c.id && caseEdit ? ( +
+

Edit case #{c.id}

+ + + + +
+ + +
+
+ ) : ( + <> +
+ {c.imageUrl ? ( +
+ +
+ ) : null} + {c.name} + {formatCredits(c.price)} cr + {c.active ? 'active' : 'inactive'} + + + +
+
{c.items.length} drops configured
+ + )}
))}
@@ -219,7 +392,7 @@ export default function Admin() {

Create item

{ e.preventDefault(); try { @@ -228,10 +401,8 @@ export default function Admin() { marketValue: Number(itemForm.marketValue), }); setItemForm({ - name: '', + ...emptyItem, rarity: rarities[0] || 'MilSpec', - marketValue: 100, - imageUrl: '', }); await load(); } catch (err) { @@ -239,55 +410,139 @@ export default function Admin() { } }} > - setItemForm({ ...itemForm, name: e.target.value })} - required - /> - - setItemForm({ ...itemForm, marketValue: e.target.value })} - required - /> + + + +
-
- {items.map((item) => ( -
- - {item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr - - -
- ))} -
+ {items.map((item) => ( +
+ {editingItemId === item.id && itemEdit ? ( +
+

Edit item #{item.id}

+ + + + +
+ + +
+
+ ) : ( +
+ {item.imageUrl ? ( +
+ +
+ ) : null} + + {item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr + + + +
+ )} +
+ ))}
)} @@ -352,7 +607,10 @@ export default function Admin() { {c.items.length === 0 &&
No drops yet.
} {c.items.map((ci) => (
- + + {ci.item.imageUrl ? ( + + ) : null} {ci.item.name} ({ci.item.rarity}) Loading…

; + if (!user) return ; + if (user.role === 'admin') return ; + + return ( +
+
+
+

Bet Item

+

Multiplayer item gambling modes.

+
+
+
+ {MODES.map((m) => + m.ready ? ( + +

{m.title}

+

{m.blurb}

+ + ) : ( +
+

{m.title}

+

{m.blurb}

+ Soon +
+ ) + )} +
+
+ ); +} diff --git a/client/src/pages/CasePage.jsx b/client/src/pages/CasePage.jsx index cb1abdb..82660c0 100644 --- a/client/src/pages/CasePage.jsx +++ b/client/src/pages/CasePage.jsx @@ -60,8 +60,8 @@ export default function CasePage() { return (
- - ← Back to dashboard + + ← Back to cases {!crate && !error &&

Loading case…

} @@ -76,12 +76,12 @@ export default function CasePage() {

+ +
+
+ {selected.length > 0 && ( + <> + + {selected.length} selected · {formatCredits(selectedValue)} cr + + + + + )} +
+
+ {error &&
{error}
} {inventory.length === 0 ? ( -
Empty for now — open a case below.
+
+ Empty for now —{' '} + + open a case + + . +
) : (
- {inventory.map((inv) => ( - + {sorted.map((inv) => ( + toggle(inv.id)} + /> ))}
)} - -
-
-
-

Cases

-

Pick a crate to open.

-
-
-
- {cases.map((c) => ( - -
- ▣ -
-
{c.name}
-
{formatCredits(c.price)} cr
-
{c.items.length} possible drops
- - ))} -
-
); } diff --git a/client/src/pages/DuelListPage.jsx b/client/src/pages/DuelListPage.jsx new file mode 100644 index 0000000..fc8946c --- /dev/null +++ b/client/src/pages/DuelListPage.jsx @@ -0,0 +1,182 @@ +import { useEffect, useState } from 'react'; +import { Link, Navigate, useNavigate } from 'react-router-dom'; +import { api, formatCredits } from '../api'; +import { useAuth } from '../AuthContext'; +import { getSocket } from '../socket'; + +const defaults = { + maxPlayers: 4, + maxItemsPerPlayer: 3, + minItemValue: 0, + minPlayersToStart: 2, +}; + +export default function DuelListPage() { + const { user, loading } = useAuth(); + const navigate = useNavigate(); + const [open, setOpen] = useState([]); + const [inProgress, setInProgress] = useState([]); + const [form, setForm] = useState(defaults); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!user || user.role === 'admin') return undefined; + let cancelled = false; + + (async () => { + try { + const list = await api.duels(); + if (!cancelled) { + setOpen(list.open || []); + setInProgress(list.inProgress || []); + } + } catch (err) { + if (!cancelled) setError(err.message); + } + })(); + + const socket = getSocket(); + const onList = (list) => { + setOpen(list.open || []); + setInProgress(list.inProgress || []); + }; + socket.emit('duels:subscribe'); + socket.on('duels:list', onList); + socket.on('connect', () => socket.emit('duels:subscribe')); + + return () => { + cancelled = true; + socket.emit('duels:unsubscribe'); + socket.off('duels:list', onList); + }; + }, [user]); + + const create = async (e) => { + e.preventDefault(); + setBusy(true); + setError(''); + try { + const data = await api.createDuel({ + maxPlayers: Number(form.maxPlayers), + maxItemsPerPlayer: Number(form.maxItemsPerPlayer), + minItemValue: Math.round(Number(form.minItemValue) * 100), + minPlayersToStart: Number(form.minPlayersToStart), + }); + navigate(`/bet/1vx/${data.room.id}`); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + if (loading) return

Loading…

; + if (!user) return ; + if (user.role === 'admin') return ; + + return ( +
+
+
+

1vX Case

+

Create a room or join an open duel.

+
+ + Modes + +
+ + {error &&
{error}
} + +
+

Create room

+
+ + + + + +
+
+ +
+

Open rooms

+ {open.length === 0 ? ( +
No open rooms.
+ ) : ( +
+ {open.map((r) => ( + +
Room #{r.id}
+
+ by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '} + {r.maxItemsPerPlayer} items max · min {formatCredits(r.minItemValue)} cr +
+
Pot {formatCredits(r.totalValue)} cr
+ + ))} +
+ )} +
+ +
+

In progress

+ {inProgress.length === 0 ? ( +
No live rooms.
+ ) : ( +
+ {inProgress.map((r) => ( + +
+ Room #{r.id} Live +
+
+ {r.playerCount} players · pot {formatCredits(r.totalValue)} cr · Spectate +
+ + ))} +
+ )} +
+
+ ); +} diff --git a/client/src/pages/DuelRoomPage.jsx b/client/src/pages/DuelRoomPage.jsx new file mode 100644 index 0000000..7010cc0 --- /dev/null +++ b/client/src/pages/DuelRoomPage.jsx @@ -0,0 +1,334 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, Navigate, useParams } from 'react-router-dom'; +import { api, formatCredits, formatChance } from '../api'; +import { useAuth } from '../AuthContext'; +import ItemTile from '../components/ItemTile'; +import CaseReel from '../components/CaseReel'; +import { getSocket } from '../socket'; + +export default function DuelRoomPage() { + const { id } = useParams(); + const { user, loading, refresh } = useAuth(); + const [room, setRoom] = useState(null); + const [inventory, setInventory] = useState([]); + const [selected, setSelected] = useState([]); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [spinning, setSpinning] = useState(false); + const [reelWinnerItemId, setReelWinnerItemId] = useState(null); + + const loadInventory = useCallback(async () => { + const inv = await api.inventory(); + setInventory(inv.inventory); + }, []); + + useEffect(() => { + if (!user || user.role === 'admin') return undefined; + let cancelled = false; + + (async () => { + try { + const [data] = await Promise.all([api.duel(id), loadInventory()]); + if (!cancelled) setRoom(data.room); + } catch (err) { + if (!cancelled) setError(err.message); + } + })(); + + const socket = getSocket(); + const roomId = Number(id); + + const onState = (state) => { + if (Number(state.id) !== roomId) return; + setRoom(state); + if (state.status === 'open' || state.status === 'resolved') { + setSpinning(false); + loadInventory().catch(() => {}); + } + }; + const onSpin = (state) => { + if (Number(state.id) !== roomId) return; + setRoom(state); + setSpinning(true); + setReelWinnerItemId(state.reelWinnerItemId); + }; + const onResult = (state) => { + if (Number(state.id) !== roomId) return; + setRoom(state); + setSpinning(false); + loadInventory().catch(() => {}); + refresh().catch(() => {}); + }; + + socket.emit('duel:subscribe', roomId); + socket.on('duel:state', onState); + socket.on('duel:spin', onSpin); + socket.on('duel:result', onResult); + socket.on('connect', () => socket.emit('duel:subscribe', roomId)); + + return () => { + cancelled = true; + socket.emit('duel:unsubscribe', roomId); + socket.off('duel:state', onState); + socket.off('duel:spin', onSpin); + socket.off('duel:result', onResult); + }; + }, [user, id, loadInventory, refresh]); + + const isSpectator = + room && + (room.status === 'in_progress' || room.status === 'spinning' || room.status === 'resolved') && + !(room.deposits || []).some((d) => d.userId === user?.id); + + const myDeposits = useMemo( + () => (room?.deposits || []).filter((d) => d.userId === user?.id), + [room, user] + ); + const slotsLeft = Math.max(0, (room?.maxItemsPerPlayer || 0) - myDeposits.length); + const potItems = useMemo( + () => + (room?.deposits || []) + .filter((d) => d.item) + .map((d) => ({ ...d.item, id: d.id })), + [room] + ); + const canBet = room?.status === 'open'; + const isCreator = room?.creatorId === user?.id; + + const toggleSelect = (invId) => { + setSelected((prev) => { + if (prev.includes(invId)) return prev.filter((x) => x !== invId); + if (prev.length >= slotsLeft) return prev; + return [...prev, invId]; + }); + }; + + const placeBet = async () => { + setBusy(true); + setError(''); + try { + const data = await api.duelBet(id, selected); + setRoom(data.room); + setSelected([]); + await loadInventory(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const leave = async () => { + setBusy(true); + setError(''); + try { + const data = await api.duelLeave(id); + setRoom(data.room); + setSelected([]); + await loadInventory(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const cancel = async () => { + setBusy(true); + setError(''); + try { + const data = await api.duelCancel(id); + setRoom(data.room); + await loadInventory(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const start = async () => { + setBusy(true); + setError(''); + try { + const data = await api.duelStart(id); + setRoom(data.room); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + if (loading) return

Loading…

; + if (!user) return ; + if (user.role === 'admin') return ; + if (!room && !error) return

Loading room…

; + + return ( +
+
+
+

Room #{id}

+

+ {room?.maxPlayers} players max · {room?.maxItemsPerPlayer} items · min{' '} + {formatCredits(room?.minItemValue || 0)} cr + {isSpectator ? ' · Spectating' : ''} +

+
+ + All rooms + +
+ + {error &&
{error}
} + +
+
+ Status + {room?.status} +
+
+ Pot + {formatCredits(room?.totalValue || 0)} cr +
+
+ Players + + {room?.playerCount || 0}/{room?.maxPlayers} + +
+
+ + {(spinning || room?.status === 'spinning') && potItems.length > 0 && ( +
+ {}} + /> + {room?.winnerUsername && ( +
+ Winner: {room.winnerUsername} +
+ )} +
+ )} + + {room?.status === 'resolved' && room.winnerUsername && ( +
+ Winner: {room.winnerUsername} took the pot ( + {formatCredits(room.totalValue)} cr) +
+ )} + +
+

Players & chances

+ {(room?.players || []).length === 0 ? ( +
No deposits yet.
+ ) : ( + + + + + + + + + + + {room.players.map((p) => ( + + + + + + + ))} + +
PlayerValueItemsChance
{p.username}{formatCredits(p.valueCents)} cr{p.itemCount}{formatChance(p.chance)}
+ )} +
+ +
+

Deposited items

+ {(room?.deposits || []).length === 0 ? ( +
Empty.
+ ) : ( +
+ {room.deposits.map((d) => ( +
+ +
{d.username}
+
+ ))} +
+ )} +
+ + {canBet && ( +
+
+
+

Your bets

+

+ {myDeposits.length}/{room.maxItemsPerPlayer} items · {slotsLeft} slot + {slotsLeft === 1 ? '' : 's'} left +

+
+
+ {myDeposits.length > 0 && ( + + )} + {isCreator && ( + <> + + + + )} +
+
+ +
+

Select items to bet

+ +
+ {inventory.length === 0 ? ( +
+ No free items — open a case. +
+ ) : ( +
+ {inventory.map((inv) => ( + toggleSelect(inv.id)} + /> + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/pages/MultiPlayersPage.jsx b/client/src/pages/MultiPlayersPage.jsx new file mode 100644 index 0000000..72877fc --- /dev/null +++ b/client/src/pages/MultiPlayersPage.jsx @@ -0,0 +1,315 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Link, Navigate } from 'react-router-dom'; +import { api, formatCredits, formatChance } from '../api'; +import { useAuth } from '../AuthContext'; +import ItemTile from '../components/ItemTile'; +import CaseReel from '../components/CaseReel'; +import { useCountdown } from '../hooks/useCountdown'; +import { getSocket } from '../socket'; + +export default function MultiPlayersPage() { + const { user, loading, refresh } = useAuth(); + const [round, setRound] = useState(null); + const [inventory, setInventory] = useState([]); + const [selected, setSelected] = useState([]); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + const [spinning, setSpinning] = useState(false); + const [reelWinnerItemId, setReelWinnerItemId] = useState(null); + const [lastWinner, setLastWinner] = useState(null); + const [revealWinner, setRevealWinner] = useState(false); + const [spinDeposits, setSpinDeposits] = useState([]); + const [spinActive, setSpinActive] = useState(false); + + const { label: timerLabel, remaining } = useCountdown( + round?.status === 'open' ? round.endsAt : null + ); + + const loadInventory = useCallback(async () => { + const inv = await api.inventory(); + setInventory(inv.inventory); + }, []); + + useEffect(() => { + if (!user || user.role === 'admin') return undefined; + let cancelled = false; + (async () => { + try { + const [jp] = await Promise.all([api.jackpot(), loadInventory()]); + if (!cancelled) setRound(jp.round); + } catch (err) { + if (!cancelled) setError(err.message); + } + })(); + + const socket = getSocket(); + const onState = (state) => { + setRound(state); + if (state?.status === 'open') { + setSpinning(false); + setReelWinnerItemId(null); + loadInventory().catch(() => {}); + } + }; + const onSpin = (state) => { + setRound(state); + setSpinDeposits(state.deposits || []); + setSpinActive(true); + setSpinning(true); + setRevealWinner(false); + setReelWinnerItemId(state.reelWinnerItemId); + setLastWinner(state.winnerUsername); + }; + const onResult = (state) => { + setRound(state); + setSpinning(false); + loadInventory().catch(() => {}); + refresh().catch(() => {}); + }; + + socket.emit('jackpot:subscribe'); + socket.on('jackpot:state', onState); + socket.on('jackpot:spin', onSpin); + socket.on('jackpot:result', onResult); + socket.on('connect', () => socket.emit('jackpot:subscribe')); + + return () => { + cancelled = true; + socket.emit('jackpot:unsubscribe'); + socket.off('jackpot:state', onState); + socket.off('jackpot:spin', onSpin); + socket.off('jackpot:result', onResult); + }; + }, [user, loadInventory, refresh]); + + const maxItems = round?.maxItemsPerPlayer || 3; + const myDeposits = useMemo( + () => (round?.deposits || []).filter((d) => d.userId === user?.id), + [round, user] + ); + const mySlotsLeft = Math.max(0, maxItems - myDeposits.length); + + const potItems = useMemo(() => { + const source = spinActive && spinDeposits.length ? spinDeposits : round?.deposits || []; + return source + .filter((d) => d.item) + .map((d) => ({ + ...d.item, + id: d.id, + ownerUsername: d.username, + ownerAvatarUrl: d.avatarUrl || '', + })); + }, [round, spinDeposits, spinActive]); + + const showReel = spinActive && potItems.length > 0; + + useEffect(() => { + if (!revealWinner) return undefined; + const t = setTimeout(() => setSpinActive(false), 5000); + return () => clearTimeout(t); + }, [revealWinner]); + + const toggleSelect = (invId) => { + setSelected((prev) => { + if (prev.includes(invId)) return prev.filter((id) => id !== invId); + if (prev.length >= mySlotsLeft) return prev; + return [...prev, invId]; + }); + }; + + const placeBet = async () => { + if (!selected.length) return; + setBusy(true); + setError(''); + try { + const data = await api.jackpotBet(selected); + setRound(data.round); + setSelected([]); + await loadInventory(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const withdraw = async (inventoryItemIds) => { + setBusy(true); + setError(''); + try { + const data = await api.jackpotWithdraw(inventoryItemIds); + setRound(data.round); + await loadInventory(); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + if (loading) return

Loading…

; + if (!user) return ; + if (user.role === 'admin') return ; + + return ( +
+
+
+

MultiPlayers Case

+

Live shared pot — win chance = your value / total pot.

+
+ + Modes + +
+ + {error &&
{error}
} + +
+
+ Round + #{round?.id || '—'} + {round?.status || '…'} +
+
+ Timer + + {round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'} + + {round?.status === 'open' && remaining <= 5 && remaining > 0 && ( + closing… + )} +
+
+ Pot + {formatCredits(round?.totalValue || 0)} cr +
+
+ + {showReel && ( +
+ { + setSpinning(false); + setRevealWinner(true); + }} + /> + {revealWinner && lastWinner && ( +
+ Winner: {lastWinner} +
+ )} +
+ )} + {!showReel && revealWinner && lastWinner && ( +
+ Winner: {lastWinner} +
+ )} + +
+

Players

+ {(round?.players || []).length === 0 ? ( +
No bets yet — be the first.
+ ) : ( + + + + + + + + + + + {round.players.map((p) => ( + + + + + + + ))} + +
PlayerValueItemsChance
{p.username}{formatCredits(p.valueCents)} cr{p.itemCount}{formatChance(p.chance)}
+ )} +
+ +
+

Pot items

+ {(round?.deposits || []).length === 0 ? ( +
Empty pot.
+ ) : ( +
+ {round.deposits.map((d) => ( +
+ +
{d.username}
+
+ ))} +
+ )} +
+ + {myDeposits.length > 0 && round?.status === 'open' && ( +
+
+

Your bets ({myDeposits.length}/{maxItems})

+ +
+
+ )} + + {round?.status === 'open' && ( +
+
+
+

Bet from inventory

+

+ Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}. +

+
+ +
+ {inventory.length === 0 ? ( +
+ No free items — open a case. +
+ ) : ( +
+ {inventory.map((inv) => ( + toggleSelect(inv.id)} + /> + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/pages/ProfilePage.jsx b/client/src/pages/ProfilePage.jsx new file mode 100644 index 0000000..ddfd07c --- /dev/null +++ b/client/src/pages/ProfilePage.jsx @@ -0,0 +1,199 @@ +import { useEffect, useState } from 'react'; +import { Navigate } from 'react-router-dom'; +import { api, formatCredits } from '../api'; +import { useAuth } from '../AuthContext'; + +export default function ProfilePage() { + const { user, loading, refresh } = useAuth(); + const [profile, setProfile] = useState(null); + const [bio, setBio] = useState(''); + const [currentPassword, setCurrentPassword] = useState(''); + const [newPassword, setNewPassword] = useState(''); + const [message, setMessage] = useState(''); + const [error, setError] = useState(''); + const [busy, setBusy] = useState(false); + + useEffect(() => { + if (!user || user.role === 'admin') return; + let cancelled = false; + (async () => { + try { + const data = await api.profile(); + if (!cancelled) { + setProfile(data.profile); + setBio(data.user.bio || ''); + } + } catch (err) { + if (!cancelled) setError(err.message); + } + })(); + return () => { + cancelled = true; + }; + }, [user]); + + const saveBio = async (e) => { + e.preventDefault(); + setBusy(true); + setError(''); + setMessage(''); + try { + await api.updateProfile({ bio }); + await refresh(); + setMessage('Profile updated.'); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const changePassword = async (e) => { + e.preventDefault(); + setBusy(true); + setError(''); + setMessage(''); + try { + await api.changePassword(currentPassword, newPassword); + setCurrentPassword(''); + setNewPassword(''); + setMessage('Password changed.'); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + } + }; + + const onAvatar = async (e) => { + const file = e.target.files?.[0]; + if (!file) return; + setBusy(true); + setError(''); + setMessage(''); + try { + await api.uploadAvatar(file); + await refresh(); + setMessage('Avatar updated.'); + } catch (err) { + setError(err.message); + } finally { + setBusy(false); + e.target.value = ''; + } + }; + + if (loading) return

Loading…

; + if (!user) return ; + if (user.role === 'admin') return ; + + return ( +
+
+
+
+

Profile

+

@{user.username}

+
+
+ + {error &&
{error}
} + {message &&
{message}
} + +
+
+
+ {user.avatarUrl ? ( + + ) : ( + {user.username.slice(0, 2).toUpperCase()} + )} +
+ +
+ Balance {formatCredits(user.balance)} cr +
+
+ +
+

Stats

+ {profile ? ( +
    +
  • + Joined{' '} + {new Date(profile.createdAt).toLocaleDateString()} +
  • +
  • + Inventory{' '} + + {profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr + +
  • +
  • + Cases opened {profile.casesOpened} +
  • +
  • + Jackpot wins {profile.jackpotWins} +
  • +
  • + Duel wins {profile.duelWins} +
  • +
+ ) : ( +

Loading stats…

+ )} +
+
+
+ +
+

Bio

+
+