first commit

This commit is contained in:
2026-07-16 17:36:21 +02:00
commit a0d7163464
45 changed files with 7481 additions and 0 deletions
+394
View File
@@ -0,0 +1,394 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
export default function Admin() {
const { user, loading, adminLogin, logout } = useAuth();
const navigate = useNavigate();
const [tab, setTab] = useState('cases');
const [cases, setCases] = useState([]);
const [items, setItems] = useState([]);
const [rarities, setRarities] = useState([]);
const [error, setError] = useState('');
const [username, setUsername] = useState('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 [dropForm, setDropForm] = useState({ caseId: '', itemId: '', dropRate: 1 });
const load = async () => {
const [c, i, r] = await Promise.all([
api.adminCases(),
api.adminItems(),
api.adminRarities(),
]);
setCases(c.cases);
setItems(i.items);
setRarities(r.rarities);
if (!dropForm.caseId && c.cases[0]) {
setDropForm((f) => ({ ...f, caseId: String(c.cases[0].id) }));
}
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] }));
}
};
useEffect(() => {
if (user?.role !== 'admin') return;
load().catch((err) => setError(err.message));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);
if (loading) return <p className="muted">Loading</p>;
if (!user || user.role !== 'admin') {
const onSubmit = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await adminLogin(username, password);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div className="auth-page">
<div className="auth-panel">
<div className="brand">CaseForge</div>
<h1>Admin</h1>
<p className="muted">Manage cases, items, and drop rates.</p>
<form className="form" onSubmit={onSubmit}>
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
{error && <div className="error">{error}</div>}
<button className="btn" type="submit" disabled={busy}>
{busy ? 'Signing in…' : 'Admin login'}
</button>
</form>
</div>
</div>
);
}
return (
<div className="main" style={{ paddingTop: '1.5rem' }}>
<header className="topnav" style={{ margin: '-1.5rem -1.5rem 1.5rem', position: 'static' }}>
<span className="brand">CaseForge Admin</span>
<nav className="nav-links">
<Link to="/leaderboard">Leaderboard</Link>
<button
type="button"
className="btn ghost"
onClick={async () => {
await logout();
navigate('/login');
}}
>
Logout
</button>
</nav>
</header>
<div className="section-head">
<div>
<h1>Admin panel</h1>
<p className="muted">Cases, loot pool, and drop weights.</p>
</div>
</div>
<div className="admin-tabs">
{['cases', 'items', 'drops'].map((t) => (
<button
key={t}
type="button"
className={tab === t ? 'active' : ''}
onClick={() => setTab(t)}
>
{t}
</button>
))}
</div>
{error && <div className="error">{error}</div>}
{tab === 'cases' && (
<div>
<div className="admin-block">
<h3>Create case</h3>
<form
className="admin-row"
onSubmit={async (e) => {
e.preventDefault();
try {
await api.createCase({
...caseForm,
price: Number(caseForm.price),
});
setCaseForm({ name: '', price: 100, imageUrl: '', active: true });
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 })}
/>
<button className="btn" type="submit">
Add
</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>
</div>
))}
</div>
)}
{tab === 'items' && (
<div>
<div className="admin-block">
<h3>Create item</h3>
<form
className="admin-row"
onSubmit={async (e) => {
e.preventDefault();
try {
await api.createItem({
...itemForm,
marketValue: Number(itemForm.marketValue),
});
setItemForm({
name: '',
rarity: rarities[0] || 'MilSpec',
marketValue: 100,
imageUrl: '',
});
await load();
} catch (err) {
setError(err.message);
}
}}
>
<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
/>
<button className="btn" type="submit">
Add
</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>
</div>
)}
{tab === 'drops' && (
<div>
<div className="admin-block">
<h3>Add drop to case</h3>
<form
className="admin-row"
onSubmit={async (e) => {
e.preventDefault();
try {
await api.addCaseItem(Number(dropForm.caseId), {
itemId: Number(dropForm.itemId),
dropRate: Number(dropForm.dropRate),
});
await load();
} catch (err) {
setError(err.message);
}
}}
>
<select
value={dropForm.caseId}
onChange={(e) => setDropForm({ ...dropForm, caseId: e.target.value })}
>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<select
value={dropForm.itemId}
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<input
type="number"
step="0.1"
min="0.01"
value={dropForm.dropRate}
onChange={(e) => setDropForm({ ...dropForm, dropRate: e.target.value })}
placeholder="Weight"
required
/>
<button className="btn" type="submit">
Add drop
</button>
</form>
</div>
{cases.map((c) => (
<div key={c.id} className="admin-block">
<h3>{c.name}</h3>
<div className="drop-list">
{c.items.length === 0 && <div className="muted">No drops yet.</div>}
{c.items.map((ci) => (
<div key={ci.id} className="drop-row">
<span>
{ci.item.name} ({ci.item.rarity})
</span>
<input
type="number"
step="0.1"
min="0.01"
defaultValue={ci.dropRate}
onBlur={async (e) => {
const dropRate = Number(e.target.value);
if (!(dropRate > 0) || dropRate === ci.dropRate) return;
try {
await api.updateCaseItem(ci.id, { dropRate });
await load();
} catch (err) {
setError(err.message);
}
}}
/>
<span className="muted">weight</span>
<button
type="button"
className="btn danger"
onClick={async () => {
await api.deleteCaseItem(ci.id);
await load();
}}
>
Remove
</button>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useEffect, useState } from 'react';
import { Link, Navigate, useParams } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
import CaseReel from '../components/CaseReel';
import ItemTile from '../components/ItemTile';
import { rarityColor } from '../rarities';
export default function CasePage() {
const { id } = useParams();
const { user, loading, updateBalance } = useAuth();
const [crate, setCrate] = useState(null);
const [error, setError] = useState('');
const [spinning, setSpinning] = useState(false);
const [winnerId, setWinnerId] = useState(null);
const [wonItem, setWonItem] = useState(null);
const [showResult, setShowResult] = useState(false);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.case(id);
if (!cancelled) setCrate(data.case);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, [id]);
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 canOpen =
crate && !spinning && user.balance >= crate.price && crate.items.length > 0;
const openCase = async () => {
if (!canOpen) return;
setError('');
setWonItem(null);
setShowResult(false);
setWinnerId(null);
setSpinning(false);
try {
const result = await api.openCase(crate.id);
updateBalance(result.user.balance);
setWonItem(result.item);
setWinnerId(result.item.id);
// start spin on next frame so reel rebuilds with winner
requestAnimationFrame(() => setSpinning(true));
} catch (err) {
setError(err.message);
}
};
return (
<div className="case-page">
<Link to="/dashboard" className="muted">
Back to dashboard
</Link>
{!crate && !error && <p className="muted">Loading case</p>}
{error && <div className="error">{error}</div>}
{crate && (
<>
<div className="case-hero">
<h1>{crate.name}</h1>
<p className="muted">
Cost {formatCredits(crate.price)} cr · Balance {formatCredits(user.balance)} cr
</p>
<button
type="button"
className={`case-icon ${canOpen ? '' : 'disabled'}`}
onClick={openCase}
disabled={!canOpen}
title="Open case"
>
</button>
<button className="btn" type="button" onClick={openCase} disabled={!canOpen}>
{spinning ? 'Opening…' : `Open for ${formatCredits(crate.price)} cr`}
</button>
</div>
{(spinning || winnerId) && (
<CaseReel
items={crate.items}
winnerId={winnerId}
spinning={spinning}
onDone={() => {
setSpinning(false);
setShowResult(true);
}}
/>
)}
{showResult && wonItem && (
<div
className="won-banner"
style={{ '--rarity': rarityColor(wonItem.rarity) }}
>
<div className="muted">You unboxed</div>
<strong>{wonItem.name}</strong>
<div style={{ color: rarityColor(wonItem.rarity) }}>{wonItem.rarity}</div>
<div className="muted">{formatCredits(wonItem.marketValue)} cr</div>
</div>
)}
<section className="section">
<div className="section-head">
<div>
<h2>Possible drops</h2>
<p className="muted">Relative drop odds for this case.</p>
</div>
</div>
<div className="grid">
{crate.items.map((item) => (
<div key={item.id}>
<ItemTile item={item} />
<div className="item-meta" style={{ marginTop: '0.35rem' }}>
{item.chance}%
</div>
</div>
))}
</div>
</section>
</>
)}
</div>
);
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, 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 [inventory, setInventory] = useState([]);
const [cases, setCases] = useState([]);
const [error, setError] = useState('');
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);
}
} 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 (
<div>
<section className="section">
<div className="section-head">
<div>
<h1>Inventory</h1>
<p className="muted">Your drops and loot.</p>
</div>
<div className="balance-pill">{formatCredits(user.balance)} cr</div>
</div>
{error && <div className="error">{error}</div>}
{inventory.length === 0 ? (
<div className="empty">Empty for now open a case below.</div>
) : (
<div className="grid">
{inventory.map((inv) => (
<ItemTile key={inv.id} item={inv.item} />
))}
</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>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { useEffect, useState } from 'react';
import { api, formatCredits } from '../api';
export default function Leaderboard() {
const [rows, setRows] = useState([]);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
try {
const data = await api.leaderboard();
if (!cancelled) setRows(data.leaderboard);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, []);
return (
<div>
<div className="section-head">
<div>
<h1>Leaderboard</h1>
<p className="muted">Richest players by balance + inventory value.</p>
</div>
</div>
{error && <div className="error">{error}</div>}
{rows.length === 0 && !error ? (
<div className="empty">No players yet.</div>
) : (
<table className="table">
<thead>
<tr>
<th>#</th>
<th>Player</th>
<th>Balance</th>
<th>Inventory</th>
<th>Total</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id}>
<td className="rank">{row.rank}</td>
<td>{row.username}</td>
<td>{formatCredits(row.balance)}</td>
<td>{formatCredits(row.inventoryValue)}</td>
<td>{formatCredits(row.totalWealth)} cr</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
}
+68
View File
@@ -0,0 +1,68 @@
import { useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
export default function Login() {
const { user, login } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
if (user) {
return <Navigate to={user.role === 'admin' ? '/admin' : '/dashboard'} replace />;
}
const onSubmit = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
const u = await login(username, password);
navigate(u.role === 'admin' ? '/admin' : '/dashboard');
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div className="auth-page">
<div className="auth-panel">
<div className="brand">CaseForge</div>
<h1>Login</h1>
<p className="muted">Open crates. Build your inventory.</p>
<form className="form" onSubmit={onSubmit}>
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="current-password"
required
/>
</label>
{error && <div className="error">{error}</div>}
<button className="btn" type="submit" disabled={busy}>
{busy ? 'Signing in…' : 'Sign in'}
</button>
</form>
<p className="muted" style={{ marginTop: '1rem' }}>
No account? <Link to="/register">Register</Link>
</p>
</div>
</div>
);
}
+70
View File
@@ -0,0 +1,70 @@
import { useState } from 'react';
import { Link, Navigate, useNavigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
export default function Register() {
const { user, register } = useAuth();
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
if (user) {
return <Navigate to="/dashboard" replace />;
}
const onSubmit = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
await register(username, password);
navigate('/dashboard');
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div className="auth-page">
<div className="auth-panel">
<div className="brand">CaseForge</div>
<h1>Register</h1>
<p className="muted">Create an account and get starting credits.</p>
<form className="form" onSubmit={onSubmit}>
<label>
Username
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
autoComplete="username"
required
minLength={3}
/>
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
required
minLength={6}
/>
</label>
{error && <div className="error">{error}</div>}
<button className="btn" type="submit" disabled={busy}>
{busy ? 'Creating…' : 'Create account'}
</button>
</form>
<p className="muted" style={{ marginTop: '1rem' }}>
Already have an account? <Link to="/login">Login</Link>
</p>
</div>
</div>
);
}