nice update

This commit is contained in:
gpatruno
2026-07-16 20:23:58 +02:00
parent a0d7163464
commit 9106c8b873
47 changed files with 5183 additions and 1732 deletions
+110 -1
View File
@@ -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"
}
}
}
}
+2 -1
View File
@@ -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",
+16
View File
@@ -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() {
<Route element={<Layout />}>
<Route path="/" element={<HomeRedirect />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/cases" element={<CasesPage />} />
<Route path="/cases/:id" element={<CasePage />} />
<Route path="/bet" element={<BetHub />} />
<Route path="/bet/multi" element={<MultiPlayersPage />} />
<Route path="/bet/1vx" element={<DuelListPage />} />
<Route path="/bet/1vx/:id" element={<DuelRoomPage />} />
<Route path="/bet/coinflip" element={<CoinflipStub />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/shop" element={<ShopPage />} />
<Route path="/leaderboard" element={<Leaderboard />} />
</Route>
<Route path="*" element={<Navigate to="/" replace />} />
+15 -1
View File
@@ -49,9 +49,23 @@ export function AuthProvider({ children }) {
setUser((prev) => (prev ? { ...prev, balance } : prev));
};
const patchUser = (partial) => {
setUser((prev) => (prev ? { ...prev, ...partial } : prev));
};
return (
<AuthContext.Provider
value={{ user, loading, login, register, adminLogin, logout, refresh, updateBalance }}
value={{
user,
loading,
login,
register,
adminLogin,
logout,
refresh,
updateBalance,
patchUser,
}}
>
{children}
</AuthContext.Provider>
+64 -4
View File
@@ -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)}%`;
}
+19 -3
View File
@@ -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 (
<div className="reel-wrap" ref={trackRef}>
<div className={`reel-wrap${showOwners ? ' reel-wrap-owners' : ''}`} ref={trackRef}>
<div className="reel-marker" />
<div
className="reel-track"
@@ -73,9 +73,25 @@ export default function CaseReel({ items, winnerId, spinning, onDone }) {
style={{ '--rarity': rarityColor(item.rarity) }}
>
<div className="glyph" style={{ color: rarityColor(item.rarity) }}>
{item.name.slice(0, 2).toUpperCase()}
{item.imageUrl ? (
<img src={item.imageUrl} alt="" className="reel-img" />
) : (
item.name.slice(0, 2).toUpperCase()
)}
</div>
<div className="label">{item.name}</div>
{showOwners && (item.ownerUsername || item.ownerAvatarUrl) ? (
<div className="reel-owner">
{item.ownerAvatarUrl ? (
<img src={item.ownerAvatarUrl} alt="" className="reel-owner-avatar" />
) : (
<span className="reel-owner-fallback">
{(item.ownerUsername || '?').slice(0, 1).toUpperCase()}
</span>
)}
<span className="reel-owner-name">{item.ownerUsername}</span>
</div>
) : null}
</div>
))}
</div>
+127
View File
@@ -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 (
<aside className="drop-feed" aria-label="Drop feed">
<div className="drop-feed-head">
<h2>Feed Drop</h2>
<p className="muted">Latest unboxes</p>
</div>
<div className="drop-feed-list">
{visible.length === 0 ? (
<div className="drop-feed-empty muted">No drops yet open a case.</div>
) : (
visible.map((d) => {
const color = rarityColor(d.item?.rarity);
return (
<div key={d.id} className="drop-feed-row" style={{ borderLeftColor: color }}>
<div className="drop-feed-item-visual" style={{ color }}>
{d.item?.imageUrl ? (
<img src={d.item.imageUrl} alt="" />
) : (
(d.item?.name || '??').slice(0, 2).toUpperCase()
)}
</div>
<div className="drop-feed-meta">
<div className="drop-feed-item-name">{d.item?.name}</div>
<div className="drop-feed-player">
{d.avatarUrl ? (
<img src={d.avatarUrl} alt="" className="drop-feed-avatar" />
) : (
<span className="drop-feed-avatar fallback">
{(d.username || '?').slice(0, 1).toUpperCase()}
</span>
)}
<span>{d.username}</span>
</div>
<div className="muted drop-feed-sub">
{d.caseName ? `${d.caseName} · ` : ''}
{formatCredits(d.item?.marketValue || 0)} cr
</div>
<div className="muted drop-feed-time">{formatDropTime(d.at)}</div>
</div>
</div>
);
})
)}
</div>
</aside>
);
}
+35 -4
View File
@@ -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 (
<div className="item-tile" style={{ borderTopColor: color }}>
<div className="item-visual" style={{ color }}>
{item.name.slice(0, 2).toUpperCase()}
<div
className={`item-tile${selected ? ' selected' : ''}${clickable ? ' selectable' : ''}${
disabled ? ' disabled' : ''
}`}
style={{ borderTopColor: color }}
onClick={disabled ? undefined : onClick}
role={clickable ? 'button' : undefined}
tabIndex={clickable && !disabled ? 0 : undefined}
onKeyDown={
clickable && !disabled
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick();
}
}
: undefined
}
>
<div className={`item-visual${hasImage ? ' has-img' : ''}`} style={{ color }}>
{hasImage ? (
<img src={item.imageUrl} alt="" loading="lazy" />
) : (
item.name.slice(0, 2).toUpperCase()
)}
</div>
<div className="item-name">{item.name}</div>
<div className="item-meta" style={{ color }}>
+48 -6
View File
@@ -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 (
<div className="app-shell">
<div className={`app-shell${feedOpen ? ' feed-open' : ''}`}>
<header className="topnav">
<NavLink to="/dashboard" className="brand">
CaseForge
</NavLink>
<nav className="nav-links">
<NavLink to="/dashboard">Dashboard</NavLink>
{user && user.role !== 'admin' && (
<>
<NavLink to="/dashboard">Inventory</NavLink>
<NavLink to="/cases">Cases</NavLink>
<NavLink to="/bet">Bet Item</NavLink>
<NavLink to="/shop">Shop</NavLink>
<button
type="button"
className={`nav-toggle${feedOpen ? ' active' : ''}`}
onClick={() => setFeedOpen((v) => !v)}
aria-pressed={feedOpen}
>
Feed Drop
</button>
</>
)}
<NavLink to="/leaderboard">Leaderboard</NavLink>
{user?.role === 'admin' && <NavLink to="/admin">Admin</NavLink>}
{user && (
@@ -20,7 +54,12 @@ export default function Layout() {
)}
{user ? (
<>
<span className="muted">{user.username}</span>
<NavLink to="/profile" className="nav-user" title="Profile">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" className="nav-avatar" />
) : null}
<span className="muted">{user.username}</span>
</NavLink>
<button type="button" className="btn ghost" onClick={logout}>
Logout
</button>
@@ -30,9 +69,12 @@ export default function Layout() {
)}
</nav>
</header>
<main className="main">
<Outlet />
</main>
<div className="app-body">
{feedOpen && user && user.role !== 'admin' && <DropFeed />}
<main className="main">
<Outlet />
</main>
</div>
</div>
);
}
+24
View File
@@ -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}` };
}
+608
View File
@@ -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;
}
}
+365 -107
View File
@@ -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 (
<div className="image-field">
{value ? (
<div className="admin-thumb">
<img src={value} alt="" />
</div>
) : (
<div className="admin-thumb empty">No img</div>
)}
<input
placeholder="Image URL or upload"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
/>
<label className="btn secondary file-btn">
{uploading ? '…' : 'Upload'}
<input type="file" accept="image/*" hidden onChange={onFile} disabled={disabled || uploading} />
</label>
{value ? (
<button type="button" className="btn ghost" onClick={() => onChange('')} disabled={disabled}>
Clear
</button>
) : null}
</div>
);
}
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 (
<div className="main" style={{ paddingTop: '1.5rem' }}>
<header className="topnav" style={{ margin: '-1.5rem -1.5rem 1.5rem', position: 'static' }}>
@@ -117,7 +221,7 @@ export default function Admin() {
<div className="section-head">
<div>
<h1>Admin panel</h1>
<p className="muted">Cases, loot pool, and drop weights.</p>
<p className="muted">Cases, loot pool, images, and drop weights.</p>
</div>
</div>
@@ -141,7 +245,7 @@ export default function Admin() {
<div className="admin-block">
<h3>Create case</h3>
<form
className="admin-row"
className="form"
onSubmit={async (e) => {
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);
}
}}
>
<input
placeholder="Name"
value={caseForm.name}
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
required
/>
<input
type="number"
placeholder="Price (cents)"
value={caseForm.price}
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
required
/>
<input
placeholder="Image URL"
value={caseForm.imageUrl}
onChange={(e) => setCaseForm({ ...caseForm, imageUrl: e.target.value })}
/>
<label>
Name
<input
value={caseForm.name}
onChange={(e) => setCaseForm({ ...caseForm, name: e.target.value })}
required
/>
</label>
<label>
Price (cents)
<input
type="number"
value={caseForm.price}
onChange={(e) => setCaseForm({ ...caseForm, price: e.target.value })}
required
/>
</label>
<label>
Image
<ImageField
value={caseForm.imageUrl}
onChange={(imageUrl) => setCaseForm({ ...caseForm, imageUrl })}
/>
</label>
<button className="btn" type="submit">
Add
Add case
</button>
</form>
</div>
{cases.map((c) => (
<div key={c.id} className="admin-block">
<div className="admin-row">
<strong>{c.name}</strong>
<span className="muted">{formatCredits(c.price)} cr</span>
<span className="muted">{c.active ? 'active' : 'inactive'}</span>
<button
type="button"
className="btn secondary"
onClick={async () => {
await api.updateCase(c.id, { active: !c.active });
await load();
}}
>
{c.active ? 'Disable' : 'Enable'}
</button>
<button
type="button"
className="btn danger"
onClick={async () => {
if (!confirm(`Delete case ${c.name}?`)) return;
await api.deleteCase(c.id);
await load();
}}
>
Delete
</button>
</div>
<div className="muted">{c.items.length} drops configured</div>
{editingCaseId === c.id && caseEdit ? (
<form className="form" onSubmit={saveCaseEdit}>
<h3>Edit case #{c.id}</h3>
<label>
Name
<input
value={caseEdit.name}
onChange={(e) => setCaseEdit({ ...caseEdit, name: e.target.value })}
required
/>
</label>
<label>
Price (cents)
<input
type="number"
value={caseEdit.price}
onChange={(e) => setCaseEdit({ ...caseEdit, price: e.target.value })}
required
/>
</label>
<label>
Image
<ImageField
value={caseEdit.imageUrl}
onChange={(imageUrl) => setCaseEdit({ ...caseEdit, imageUrl })}
/>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={caseEdit.active}
onChange={(e) => setCaseEdit({ ...caseEdit, active: e.target.checked })}
/>
Active
</label>
<div className="row-actions">
<button className="btn" type="submit">
Save
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingCaseId(null);
setCaseEdit(null);
}}
>
Cancel
</button>
</div>
</form>
) : (
<>
<div className="admin-row">
{c.imageUrl ? (
<div className="admin-thumb">
<img src={c.imageUrl} alt="" />
</div>
) : null}
<strong>{c.name}</strong>
<span className="muted">{formatCredits(c.price)} cr</span>
<span className="muted">{c.active ? 'active' : 'inactive'}</span>
<button type="button" className="btn secondary" onClick={() => startEditCase(c)}>
Edit
</button>
<button
type="button"
className="btn secondary"
onClick={async () => {
await api.updateCase(c.id, { active: !c.active });
await load();
}}
>
{c.active ? 'Disable' : 'Enable'}
</button>
<button
type="button"
className="btn danger"
onClick={async () => {
if (!confirm(`Delete case ${c.name}?`)) return;
await api.deleteCase(c.id);
await load();
}}
>
Delete
</button>
</div>
<div className="muted">{c.items.length} drops configured</div>
</>
)}
</div>
))}
</div>
@@ -219,7 +392,7 @@ export default function Admin() {
<div className="admin-block">
<h3>Create item</h3>
<form
className="admin-row"
className="form"
onSubmit={async (e) => {
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() {
}
}}
>
<input
placeholder="Name"
value={itemForm.name}
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
required
/>
<select
value={itemForm.rarity}
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
>
{rarities.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<input
type="number"
placeholder="Value (cents)"
value={itemForm.marketValue}
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
required
/>
<label>
Name
<input
value={itemForm.name}
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
required
/>
</label>
<label>
Rarity
<select
value={itemForm.rarity}
onChange={(e) => setItemForm({ ...itemForm, rarity: e.target.value })}
>
{rarities.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<label>
Value (cents)
<input
type="number"
value={itemForm.marketValue}
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
required
/>
</label>
<label>
Image
<ImageField
value={itemForm.imageUrl}
onChange={(imageUrl) => setItemForm({ ...itemForm, imageUrl })}
/>
</label>
<button className="btn" type="submit">
Add
Add item
</button>
</form>
</div>
<div className="admin-block">
{items.map((item) => (
<div key={item.id} className="admin-row">
<span>
{item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr
</span>
<button
type="button"
className="btn danger"
onClick={async () => {
if (!confirm(`Delete ${item.name}?`)) return;
await api.deleteItem(item.id);
await load();
}}
>
Delete
</button>
</div>
))}
</div>
{items.map((item) => (
<div key={item.id} className="admin-block">
{editingItemId === item.id && itemEdit ? (
<form className="form" onSubmit={saveItemEdit}>
<h3>Edit item #{item.id}</h3>
<label>
Name
<input
value={itemEdit.name}
onChange={(e) => setItemEdit({ ...itemEdit, name: e.target.value })}
required
/>
</label>
<label>
Rarity
<select
value={itemEdit.rarity}
onChange={(e) => setItemEdit({ ...itemEdit, rarity: e.target.value })}
>
{rarities.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<label>
Value (cents)
<input
type="number"
value={itemEdit.marketValue}
onChange={(e) => setItemEdit({ ...itemEdit, marketValue: e.target.value })}
required
/>
</label>
<label>
Image
<ImageField
value={itemEdit.imageUrl}
onChange={(imageUrl) => setItemEdit({ ...itemEdit, imageUrl })}
/>
</label>
<div className="row-actions">
<button className="btn" type="submit">
Save
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingItemId(null);
setItemEdit(null);
}}
>
Cancel
</button>
</div>
</form>
) : (
<div className="admin-row">
{item.imageUrl ? (
<div className="admin-thumb">
<img src={item.imageUrl} alt="" />
</div>
) : null}
<span>
{item.name} · {item.rarity} · {formatCredits(item.marketValue)} cr
</span>
<button
type="button"
className="btn secondary"
onClick={() => startEditItem(item)}
>
Edit
</button>
<button
type="button"
className="btn danger"
onClick={async () => {
if (!confirm(`Delete ${item.name}?`)) return;
await api.deleteItem(item.id);
await load();
}}
>
Delete
</button>
</div>
)}
</div>
))}
</div>
)}
@@ -352,7 +607,10 @@ export default function Admin() {
{c.items.length === 0 && <div className="muted">No drops yet.</div>}
{c.items.map((ci) => (
<div key={ci.id} className="drop-row">
<span>
<span className="drop-item-cell">
{ci.item.imageUrl ? (
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
) : null}
{ci.item.name} ({ci.item.rarity})
</span>
<input
+58
View File
@@ -0,0 +1,58 @@
import { Link, Navigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
const MODES = [
{
to: '/bet/multi',
title: 'MultiPlayers Case',
blurb: 'Join the live pot. Bet up to 3 items — chance scales with value. Winner takes all.',
ready: true,
},
{
to: '/bet/1vx',
title: '1vX Case',
blurb: 'Create or join custom rooms. Set player/item limits and min value. Spectate live duels.',
ready: true,
},
{
to: '/bet/coinflip',
title: 'Coinflip',
blurb: 'Classic 1v1 item flip. Coming soon.',
ready: false,
},
];
export default function BetHub() {
const { user, loading } = useAuth();
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Bet Item</h1>
<p className="muted">Multiplayer item gambling modes.</p>
</div>
</div>
<div className="mode-grid">
{MODES.map((m) =>
m.ready ? (
<Link key={m.to} to={m.to} className="mode-card">
<h2>{m.title}</h2>
<p className="muted">{m.blurb}</p>
</Link>
) : (
<div key={m.to} className="mode-card stub">
<h2>{m.title}</h2>
<p className="muted">{m.blurb}</p>
<span className="badge">Soon</span>
</div>
)
)}
</div>
</section>
);
}
+7 -4
View File
@@ -60,8 +60,8 @@ export default function CasePage() {
return (
<div className="case-page">
<Link to="/dashboard" className="muted">
Back to dashboard
<Link to="/cases" className="muted">
Back to cases
</Link>
{!crate && !error && <p className="muted">Loading case</p>}
@@ -76,12 +76,12 @@ export default function CasePage() {
</p>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}`}
className={`case-icon ${canOpen ? '' : 'disabled'}${crate.imageUrl ? ' has-img' : ''}`}
onClick={openCase}
disabled={!canOpen}
title="Open case"
>
{crate.imageUrl ? <img src={crate.imageUrl} alt="" /> : '▣'}
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
@@ -105,6 +105,9 @@ export default function CasePage() {
className="won-banner"
style={{ '--rarity': rarityColor(wonItem.rarity) }}
>
{wonItem.imageUrl ? (
<img src={wonItem.imageUrl} alt="" className="won-img" />
) : null}
<div className="muted">You unboxed</div>
<strong>{wonItem.name}</strong>
<div style={{ color: rarityColor(wonItem.rarity) }}>{wonItem.rarity}</div>
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
export default function CasesPage() {
const { user, loading } = useAuth();
const [cases, setCases] = useState([]);
const [error, setError] = useState('');
useEffect(() => {
if (!user || user.role === 'admin') return;
let cancelled = false;
(async () => {
try {
const cs = await api.cases();
if (!cancelled) setCases(cs.cases);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [user]);
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Cases</h1>
<p className="muted">Pick a crate to open.</p>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="grid case-grid">
{cases.map((c) => (
<Link key={c.id} to={`/cases/${c.id}`} className="case-tile">
<div className={`case-visual${c.imageUrl ? ' has-img' : ''}`} style={{ color: 'var(--accent)' }}>
{c.imageUrl ? <img src={c.imageUrl} alt="" loading="lazy" /> : '▣'}
</div>
<div className="case-name">{c.name}</div>
<div className="case-meta">{formatCredits(c.price)} cr</div>
<div className="case-meta">{c.items.length} possible drops</div>
</Link>
))}
</div>
{cases.length === 0 && !error && <div className="empty">No active cases.</div>}
</section>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { Navigate, Link } from 'react-router-dom';
import { useAuth } from '../AuthContext';
export default function CoinflipStub() {
const { user, loading } = useAuth();
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<section className="section">
<div className="section-head">
<div>
<h1>Coinflip</h1>
<p className="muted">1v1 item flip coming soon.</p>
</div>
<Link to="/bet" className="btn secondary">
Back
</Link>
</div>
<div className="empty stub-panel">
<p>This mode is not playable yet. Use MultiPlayers Case or 1vX Case in the meantime.</p>
<div className="row-actions" style={{ marginTop: '1rem' }}>
<Link to="/bet/multi" className="btn">
MultiPlayers
</Link>
<Link to="/bet/1vx" className="btn secondary">
1vX Case
</Link>
</div>
</div>
</section>
);
}
+106 -33
View File
@@ -1,25 +1,28 @@
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { Link, Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import ItemTile from '../components/ItemTile';
export default function Dashboard() {
const { user, loading } = useAuth();
const { user, loading, patchUser } = useAuth();
const [inventory, setInventory] = useState([]);
const [cases, setCases] = useState([]);
const [error, setError] = useState('');
const [sort, setSort] = useState('recent');
const [selected, setSelected] = useState([]);
const [busy, setBusy] = useState(false);
const load = async () => {
const inv = await api.inventory();
setInventory(inv.inventory);
};
useEffect(() => {
if (!user || user.role === 'admin') return;
let cancelled = false;
(async () => {
try {
const [inv, cs] = await Promise.all([api.inventory(), api.cases()]);
if (!cancelled) {
setInventory(inv.inventory);
setCases(cs.cases);
}
await load();
} catch (err) {
if (!cancelled) setError(err.message);
}
@@ -29,6 +32,46 @@ export default function Dashboard() {
};
}, [user]);
const sorted = useMemo(() => {
const list = [...inventory];
if (sort === 'value') {
list.sort((a, b) => b.item.marketValue - a.item.marketValue);
} else {
list.sort((a, b) => new Date(b.obtainedAt) - new Date(a.obtainedAt));
}
return list;
}, [inventory, sort]);
const selectedValue = useMemo(
() =>
inventory
.filter((inv) => selected.includes(inv.id))
.reduce((sum, inv) => sum + inv.item.marketValue, 0),
[inventory, selected]
);
const toggle = (id) => {
setSelected((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const sell = async () => {
if (!selected.length) return;
setBusy(true);
setError('');
try {
const data = await api.sellInventory(selected);
patchUser({ balance: data.user.balance });
setSelected([]);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
@@ -39,42 +82,72 @@ export default function Dashboard() {
<div className="section-head">
<div>
<h1>Inventory</h1>
<p className="muted">Your drops and loot.</p>
<p className="muted">Select items to sell at market value.</p>
</div>
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
</div>
<div className="inv-toolbar">
<div className="sort-toggle" role="group" aria-label="Sort inventory">
<button
type="button"
className={sort === 'recent' ? 'active' : ''}
onClick={() => setSort('recent')}
>
Most recent
</button>
<button
type="button"
className={sort === 'value' ? 'active' : ''}
onClick={() => setSort('value')}
>
Highest value
</button>
</div>
<div className="row-actions">
{selected.length > 0 && (
<>
<span className="muted">
{selected.length} selected · {formatCredits(selectedValue)} cr
</span>
<button
type="button"
className="btn ghost"
onClick={() => setSelected([])}
disabled={busy}
>
Clear
</button>
<button type="button" className="btn" onClick={sell} disabled={busy}>
Sell
</button>
</>
)}
</div>
</div>
{error && <div className="error">{error}</div>}
{inventory.length === 0 ? (
<div className="empty">Empty for now open a case below.</div>
<div className="empty">
Empty for now {' '}
<Link to="/cases" style={{ color: 'var(--accent)' }}>
open a case
</Link>
.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
<ItemTile key={inv.id} item={inv.item} />
{sorted.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
selected={selected.includes(inv.id)}
onClick={() => toggle(inv.id)}
/>
))}
</div>
)}
</section>
<section className="section">
<div className="section-head">
<div>
<h2>Cases</h2>
<p className="muted">Pick a crate to open.</p>
</div>
</div>
<div className="grid case-grid">
{cases.map((c) => (
<Link key={c.id} to={`/cases/${c.id}`} className="case-tile">
<div className="case-visual" style={{ color: 'var(--accent)' }}>
</div>
<div className="case-name">{c.name}</div>
<div className="case-meta">{formatCredits(c.price)} cr</div>
<div className="case-meta">{c.items.length} possible drops</div>
</Link>
))}
</div>
</section>
</div>
);
}
+182
View File
@@ -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 <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div>
<div className="section-head">
<div>
<h1>1vX Case</h1>
<p className="muted">Create a room or join an open duel.</p>
</div>
<Link to="/bet" className="btn secondary">
Modes
</Link>
</div>
{error && <div className="error">{error}</div>}
<section className="section admin-block">
<h2>Create room</h2>
<form className="form form-inline" onSubmit={create}>
<label>
Max players (210)
<input
type="number"
min={2}
max={10}
value={form.maxPlayers}
onChange={(e) => setForm({ ...form, maxPlayers: e.target.value })}
/>
</label>
<label>
Max items / player (110)
<input
type="number"
min={1}
max={10}
value={form.maxItemsPerPlayer}
onChange={(e) => setForm({ ...form, maxItemsPerPlayer: e.target.value })}
/>
</label>
<label>
Min item value (cr)
<input
type="number"
min={0}
step={0.01}
value={form.minItemValue}
onChange={(e) => setForm({ ...form, minItemValue: e.target.value })}
/>
</label>
<label>
Min players to start
<input
type="number"
min={2}
max={10}
value={form.minPlayersToStart}
onChange={(e) => setForm({ ...form, minPlayersToStart: e.target.value })}
/>
</label>
<button type="submit" className="btn" disabled={busy}>
Create
</button>
</form>
</section>
<section className="section">
<h2>Open rooms</h2>
{open.length === 0 ? (
<div className="empty">No open rooms.</div>
) : (
<div className="room-list">
{open.map((r) => (
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card">
<div className="room-title">Room #{r.id}</div>
<div className="muted">
by {r.creatorUsername} · {r.playerCount}/{r.maxPlayers} players ·{' '}
{r.maxItemsPerPlayer} items max · min {formatCredits(r.minItemValue)} cr
</div>
<div className="muted">Pot {formatCredits(r.totalValue)} cr</div>
</Link>
))}
</div>
)}
</section>
<section className="section">
<h2>In progress</h2>
{inProgress.length === 0 ? (
<div className="empty">No live rooms.</div>
) : (
<div className="room-list">
{inProgress.map((r) => (
<Link key={r.id} to={`/bet/1vx/${r.id}`} className="room-card live">
<div className="room-title">
Room #{r.id} <span className="badge">Live</span>
</div>
<div className="muted">
{r.playerCount} players · pot {formatCredits(r.totalValue)} cr · Spectate
</div>
</Link>
))}
</div>
)}
</section>
</div>
);
}
+334
View File
@@ -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 <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
if (!room && !error) return <p className="muted">Loading room</p>;
return (
<div className="bet-page">
<div className="section-head">
<div>
<h1>Room #{id}</h1>
<p className="muted">
{room?.maxPlayers} players max · {room?.maxItemsPerPlayer} items · min{' '}
{formatCredits(room?.minItemValue || 0)} cr
{isSpectator ? ' · Spectating' : ''}
</p>
</div>
<Link to="/bet/1vx" className="btn secondary">
All rooms
</Link>
</div>
{error && <div className="error">{error}</div>}
<div className="jackpot-status">
<div className="timer-block">
<span className="muted">Status</span>
<span className={`status-pill ${room?.status || ''}`}>{room?.status}</span>
</div>
<div className="timer-block">
<span className="muted">Pot</span>
<strong className="gold">{formatCredits(room?.totalValue || 0)} cr</strong>
</div>
<div className="timer-block">
<span className="muted">Players</span>
<strong>
{room?.playerCount || 0}/{room?.maxPlayers}
</strong>
</div>
</div>
{(spinning || room?.status === 'spinning') && potItems.length > 0 && (
<div className="section">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || room?.status === 'spinning'}
onDone={() => {}}
/>
{room?.winnerUsername && (
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
Winner: <strong>{room.winnerUsername}</strong>
</div>
)}
</div>
)}
{room?.status === 'resolved' && room.winnerUsername && (
<div className="won-banner section">
Winner: <strong>{room.winnerUsername}</strong> took the pot (
{formatCredits(room.totalValue)} cr)
</div>
)}
<section className="section">
<h2>Players & chances</h2>
{(room?.players || []).length === 0 ? (
<div className="empty">No deposits yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Player</th>
<th>Value</th>
<th>Items</th>
<th>Chance</th>
</tr>
</thead>
<tbody>
{room.players.map((p) => (
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
<td>{p.username}</td>
<td>{formatCredits(p.valueCents)} cr</td>
<td>{p.itemCount}</td>
<td>{formatChance(p.chance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="section">
<h2>Deposited items</h2>
{(room?.deposits || []).length === 0 ? (
<div className="empty">Empty.</div>
) : (
<div className="grid">
{room.deposits.map((d) => (
<div key={d.id} className="deposit-wrap">
<ItemTile item={d.item} />
<div className="deposit-owner muted">{d.username}</div>
</div>
))}
</div>
)}
</section>
{canBet && (
<section className="section">
<div className="section-head">
<div>
<h2>Your bets</h2>
<p className="muted">
{myDeposits.length}/{room.maxItemsPerPlayer} items · {slotsLeft} slot
{slotsLeft === 1 ? '' : 's'} left
</p>
</div>
<div className="row-actions">
{myDeposits.length > 0 && (
<button type="button" className="btn danger" disabled={busy} onClick={leave}>
Leave / withdraw
</button>
)}
{isCreator && (
<>
<button type="button" className="btn secondary" disabled={busy} onClick={cancel}>
Cancel room
</button>
<button type="button" className="btn" disabled={busy} onClick={start}>
Start now
</button>
</>
)}
</div>
</div>
<div className="section-head">
<p className="muted">Select items to bet</p>
<button
type="button"
className="btn"
disabled={busy || !selected.length || slotsLeft === 0}
onClick={placeBet}
>
Deposit
</button>
</div>
{inventory.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
selected={selected.includes(inv.id)}
disabled={
(slotsLeft === 0 && !selected.includes(inv.id)) ||
inv.item.marketValue < (room.minItemValue || 0)
}
onClick={() => toggleSelect(inv.id)}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
+315
View File
@@ -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 <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div className="bet-page">
<div className="section-head">
<div>
<h1>MultiPlayers Case</h1>
<p className="muted">Live shared pot win chance = your value / total pot.</p>
</div>
<Link to="/bet" className="btn secondary">
Modes
</Link>
</div>
{error && <div className="error">{error}</div>}
<div className="jackpot-status">
<div className="timer-block">
<span className="muted">Round</span>
<strong>#{round?.id || '—'}</strong>
<span className={`status-pill ${round?.status || ''}`}>{round?.status || '…'}</span>
</div>
<div className="timer-block">
<span className="muted">Timer</span>
<strong className="timer-digits">
{round?.status === 'open' ? timerLabel : round?.status === 'spinning' ? 'SPIN' : '—'}
</strong>
{round?.status === 'open' && remaining <= 5 && remaining > 0 && (
<span className="muted">closing</span>
)}
</div>
<div className="timer-block">
<span className="muted">Pot</span>
<strong className="gold">{formatCredits(round?.totalValue || 0)} cr</strong>
</div>
</div>
{showReel && (
<div className="section">
<CaseReel
items={potItems}
winnerId={reelWinnerItemId || potItems[0]?.id}
spinning={spinning || round?.status === 'spinning'}
showOwners
onDone={() => {
setSpinning(false);
setRevealWinner(true);
}}
/>
{revealWinner && lastWinner && (
<div className="won-banner" style={{ marginTop: '0.75rem' }}>
Winner: <strong>{lastWinner}</strong>
</div>
)}
</div>
)}
{!showReel && revealWinner && lastWinner && (
<div className="won-banner section">
Winner: <strong>{lastWinner}</strong>
</div>
)}
<section className="section">
<h2>Players</h2>
{(round?.players || []).length === 0 ? (
<div className="empty">No bets yet be the first.</div>
) : (
<table className="table">
<thead>
<tr>
<th>Player</th>
<th>Value</th>
<th>Items</th>
<th>Chance</th>
</tr>
</thead>
<tbody>
{round.players.map((p) => (
<tr key={p.userId} className={p.userId === user.id ? 'row-me' : ''}>
<td>{p.username}</td>
<td>{formatCredits(p.valueCents)} cr</td>
<td>{p.itemCount}</td>
<td>{formatChance(p.chance)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="section">
<h2>Pot items</h2>
{(round?.deposits || []).length === 0 ? (
<div className="empty">Empty pot.</div>
) : (
<div className="grid">
{round.deposits.map((d) => (
<div key={d.id} className="deposit-wrap">
<ItemTile item={d.item} />
<div className="deposit-owner muted">{d.username}</div>
</div>
))}
</div>
)}
</section>
{myDeposits.length > 0 && round?.status === 'open' && (
<section className="section">
<div className="section-head">
<h2>Your bets ({myDeposits.length}/{maxItems})</h2>
<button
type="button"
className="btn danger"
disabled={busy}
onClick={() => withdraw(myDeposits.map((d) => d.inventoryItemId))}
>
Withdraw all
</button>
</div>
</section>
)}
{round?.status === 'open' && (
<section className="section">
<div className="section-head">
<div>
<h2>Bet from inventory</h2>
<p className="muted">
Select up to {mySlotsLeft} more item{mySlotsLeft === 1 ? '' : 's'}.
</p>
</div>
<button
type="button"
className="btn"
disabled={busy || !selected.length || mySlotsLeft === 0}
onClick={placeBet}
>
Place bet
</button>
</div>
{inventory.length === 0 ? (
<div className="empty">
No free items <Link to="/cases">open a case</Link>.
</div>
) : (
<div className="grid">
{inventory.map((inv) => (
<ItemTile
key={inv.id}
item={inv.item}
selected={selected.includes(inv.id)}
disabled={
mySlotsLeft === 0 && !selected.includes(inv.id)
}
onClick={() => toggleSelect(inv.id)}
/>
))}
</div>
)}
</section>
)}
</div>
);
}
+199
View File
@@ -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 <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Profile</h1>
<p className="muted">@{user.username}</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
<div className="profile-layout">
<div className="profile-avatar-block admin-block">
<div className="avatar-preview">
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" />
) : (
<span>{user.username.slice(0, 2).toUpperCase()}</span>
)}
</div>
<label className="btn secondary file-btn">
Change photo
<input type="file" accept="image/*" hidden onChange={onAvatar} disabled={busy} />
</label>
<div className="muted" style={{ marginTop: '0.75rem' }}>
Balance {formatCredits(user.balance)} cr
</div>
</div>
<div className="profile-stats admin-block">
<h2>Stats</h2>
{profile ? (
<ul className="stat-list">
<li>
Joined{' '}
<strong>{new Date(profile.createdAt).toLocaleDateString()}</strong>
</li>
<li>
Inventory{' '}
<strong>
{profile.inventoryCount} items · {formatCredits(profile.inventoryValue)} cr
</strong>
</li>
<li>
Cases opened <strong>{profile.casesOpened}</strong>
</li>
<li>
Jackpot wins <strong>{profile.jackpotWins}</strong>
</li>
<li>
Duel wins <strong>{profile.duelWins}</strong>
</li>
</ul>
) : (
<p className="muted">Loading stats</p>
)}
</div>
</div>
</section>
<section className="section admin-block">
<h2>Bio</h2>
<form className="form" onSubmit={saveBio}>
<label>
About you
<textarea
rows={3}
maxLength={280}
value={bio}
onChange={(e) => setBio(e.target.value)}
/>
</label>
<button type="submit" className="btn" disabled={busy}>
Save bio
</button>
</form>
</section>
<section className="section admin-block">
<h2>Change password</h2>
<form className="form" onSubmit={changePassword}>
<label>
Current password
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
New password
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={6}
required
/>
</label>
<button type="submit" className="btn" disabled={busy}>
Update password
</button>
</form>
</section>
</div>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useCallback, useEffect, useState } from 'react';
import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
function useAdCountdown(nextAdAt) {
const [remaining, setRemaining] = useState(0);
useEffect(() => {
if (!nextAdAt) {
setRemaining(0);
return undefined;
}
const tick = () => {
const ms = new Date(nextAdAt).getTime() - Date.now();
setRemaining(Math.max(0, Math.ceil(ms / 1000)));
};
tick();
const id = setInterval(tick, 250);
return () => clearInterval(id);
}, [nextAdAt]);
return remaining;
}
export default function ShopPage() {
const { user, loading, patchUser } = useAuth();
const [packages, setPackages] = useState([]);
const [ad, setAd] = useState(null);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
const [watching, setWatching] = useState(false);
const remaining = useAdCountdown(ad?.ready ? null : ad?.nextAdAt);
const load = useCallback(async () => {
const data = await api.shop();
setPackages(data.packages || []);
setAd(data.ad);
patchUser({
balance: data.user.balance,
osuBalance: data.user.osuBalance,
lastAdAt: data.user.lastAdAt,
});
}, [patchUser]);
useEffect(() => {
if (!user || user.role === 'admin') return;
let cancelled = false;
(async () => {
try {
await load();
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [user, load]);
const buy = async (packageId) => {
setBusy(true);
setError('');
setMessage('');
try {
const data = await api.shopBuy(packageId);
patchUser({
balance: data.user.balance,
osuBalance: data.user.osuBalance,
});
setMessage(`Bought ${data.package.name} — +${formatCredits(data.package.credits)} cr`);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const watchAd = async () => {
setBusy(true);
setError('');
setMessage('');
setWatching(true);
// Simulated short ad
await new Promise((r) => setTimeout(r, 1800));
try {
const data = await api.shopAd();
patchUser({
balance: data.user.balance,
osuBalance: data.user.osuBalance,
lastAdAt: data.user.lastAdAt,
});
setAd(data.ad);
setMessage(`Ad reward: +${formatCredits(data.reward)} cr`);
} catch (err) {
setError(err.message);
if (err.message?.includes('available in') || err.nextAdAt) {
try {
await load();
} catch {
/* ignore */
}
}
} finally {
setWatching(false);
setBusy(false);
}
};
if (loading) return <p className="muted">Loading</p>;
if (!user) return <Navigate to="/login" replace />;
if (user.role === 'admin') return <Navigate to="/admin" replace />;
const adReady = ad?.ready || (ad?.nextAdAt && remaining === 0);
return (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Shop</h1>
<p className="muted">Spend osu for credit packs, or watch an ad every minute.</p>
</div>
<div className="row-actions">
<span className="osu-pill">{user.osuBalance ?? 0} osu</span>
<span className="balance-pill">{formatCredits(user.balance)} cr</span>
</div>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
<div className="admin-block ad-panel">
<h2>Watch an ad</h2>
<p className="muted">
Earn between {formatCredits(ad?.rewardMin || 10000)} and{' '}
{formatCredits(ad?.rewardMax || 20000)} cr. Cooldown: 1 minute.
</p>
{watching ? (
<div className="ad-sim">
<div className="ad-bar" />
<p className="muted">Playing ad</p>
</div>
) : (
<button
type="button"
className="btn"
disabled={busy || !adReady}
onClick={watchAd}
>
{adReady
? 'Watch ad'
: `Available in ${String(Math.floor(remaining / 60)).padStart(2, '0')}:${String(
remaining % 60
).padStart(2, '0')}`}
</button>
)}
</div>
</section>
<section className="section">
<h2>Credit packs</h2>
<p className="muted" style={{ marginBottom: '1rem' }}>
Buy credits with osu (demo currency).
</p>
<div className="mode-grid">
{packages.map((p) => (
<div key={p.id} className="mode-card shop-pack">
<h2>{p.name}</h2>
<p className="pack-credits">{p.blurb}</p>
<p className="muted">{p.osuCost} osu</p>
<button
type="button"
className="btn"
disabled={busy || (user.osuBalance ?? 0) < p.osuCost}
onClick={() => buy(p.id)}
>
Buy
</button>
</div>
))}
</div>
</section>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { io } from 'socket.io-client';
let socket = null;
export function getSocket() {
if (!socket) {
socket = io({
path: '/socket.io',
withCredentials: true,
autoConnect: true,
reconnection: true,
reconnectionDelay: 500,
reconnectionAttempts: Infinity,
});
}
return socket;
}
+10
View File
@@ -4,12 +4,22 @@ import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
host: true,
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/uploads': {
target: 'http://localhost:3001',
changeOrigin: true,
},
'/socket.io': {
target: 'http://localhost:3001',
changeOrigin: true,
ws: true,
},
},
},
});