This commit is contained in:
gpatruno
2026-07-18 17:25:56 +02:00
parent 2614d679c8
commit 2168998679
26 changed files with 2760 additions and 894 deletions
+1 -1
View File
@@ -38,7 +38,7 @@ export default function App() {
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/admin" element={<Admin />} />
<Route path="/admin/*" element={<Admin />} />
<Route element={<Layout />}>
<Route path="/" element={<HomeRedirect />} />
<Route path="/dashboard" element={<Dashboard />} />
+227
View File
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import ImageField from './ImageField';
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
export default function AdminCases() {
const [cases, setCases] = useState([]);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [caseForm, setCaseForm] = useState(emptyCase);
const [editingId, setEditingId] = useState(null);
const [edit, setEdit] = useState(null);
const load = useCallback(async () => {
const data = await api.adminCases();
setCases(data.cases);
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return cases;
return cases.filter((c) => c.name.toLowerCase().includes(q));
}, [cases, query]);
const startEdit = (c) => {
setEditingId(c.id);
setEdit({
name: c.name,
price: c.price,
imageUrl: c.imageUrl || '',
active: c.active,
});
};
return (
<div>
<div className="section-head">
<div>
<h1>Cases</h1>
<p className="muted">{cases.length} crates · create, enable, price & art</p>
</div>
<input
className="admin-search"
placeholder="Search cases…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{error && <div className="error">{error}</div>}
<div className="admin-block">
<h3>Create case</h3>
<form
className="form admin-form-grid"
onSubmit={async (e) => {
e.preventDefault();
setError('');
try {
await api.createCase({ ...caseForm, price: Number(caseForm.price) });
setCaseForm(emptyCase);
await load();
} catch (err) {
setError(err.message);
}
}}
>
<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 className="admin-form-span">
Image
<ImageField
value={caseForm.imageUrl}
onChange={(imageUrl) => setCaseForm({ ...caseForm, imageUrl })}
/>
</label>
<button className="btn" type="submit">
Add case
</button>
</form>
</div>
<div className="admin-card-list">
{filtered.map((c) => (
<div key={c.id} className="admin-block">
{editingId === c.id && edit ? (
<form
className="form admin-form-grid"
onSubmit={async (e) => {
e.preventDefault();
setError('');
try {
await api.updateCase(editingId, {
name: edit.name,
price: Number(edit.price),
imageUrl: edit.imageUrl,
active: edit.active,
});
setEditingId(null);
setEdit(null);
await load();
} catch (err) {
setError(err.message);
}
}}
>
<h3 className="admin-form-span">Edit #{c.id}</h3>
<label>
Name
<input
value={edit.name}
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
required
/>
</label>
<label>
Price (cents)
<input
type="number"
value={edit.price}
onChange={(e) => setEdit({ ...edit, price: e.target.value })}
required
/>
</label>
<label className="admin-form-span">
Image
<ImageField
value={edit.imageUrl}
onChange={(imageUrl) => setEdit({ ...edit, imageUrl })}
/>
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={edit.active}
onChange={(e) => setEdit({ ...edit, active: e.target.checked })}
/>
Active
</label>
<div className="row-actions admin-form-span">
<button className="btn" type="submit">
Save
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingId(null);
setEdit(null);
}}
>
Cancel
</button>
</div>
</form>
) : (
<>
<div className="admin-row">
{c.imageUrl ? (
<div className="admin-thumb">
<img src={c.imageUrl} alt="" />
</div>
) : (
<div className="admin-thumb empty"></div>
)}
<div className="admin-row-main">
<strong>{c.name}</strong>
<div className="muted">
{formatCredits(c.price)} cr · {c.items.length} drops ·{' '}
<span className={c.active ? 'admin-pill-on' : 'admin-pill-off'}>
{c.active ? 'active' : 'inactive'}
</span>
</div>
</div>
<button type="button" className="btn secondary" onClick={() => startEdit(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>
))}
</div>
</div>
);
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { api, formatCredits, formatLastConnection } from '../api';
export default function AdminDashboard() {
const [data, setData] = useState(null);
const [error, setError] = useState('');
useEffect(() => {
let cancelled = false;
(async () => {
try {
const res = await api.adminStats();
if (!cancelled) setData(res);
} catch (err) {
if (!cancelled) setError(err.message);
}
})();
return () => {
cancelled = true;
};
}, []);
if (error) return <div className="error">{error}</div>;
if (!data) return <p className="muted">Loading overview</p>;
const s = data.stats;
return (
<div>
<div className="section-head">
<div>
<h1>Overview</h1>
<p className="muted">Live snapshot of CaseForge.</p>
</div>
</div>
<div className="admin-stat-grid">
<div className="admin-stat">
<span className="admin-stat-label">Players</span>
<strong>{s.players}</strong>
<span className="muted">{s.onlinePlayers} online · {s.openWindows} windows</span>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Cases</span>
<strong>
{s.casesActive}/{s.casesTotal}
</strong>
<span className="muted">active / total</span>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Catalog items</span>
<strong>{s.items}</strong>
<span className="muted">{s.inventoryItems} in inventories</span>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Case opens</span>
<strong>{s.caseOpens}</strong>
<span className="muted">all time</span>
</div>
<div className="admin-stat">
<span className="admin-stat-label">Achievements</span>
<strong>{s.achievementsUnlocked}</strong>
<span className="muted">{s.achievementsDefined} defined</span>
</div>
</div>
<div className="admin-quick-links">
<Link to="/admin/players" className="btn secondary">
Manage players
</Link>
<Link to="/admin/cases" className="btn secondary">
Manage cases
</Link>
<Link to="/admin/items" className="btn secondary">
Manage items
</Link>
<Link to="/admin/drops" className="btn secondary">
Drop rates
</Link>
</div>
<section className="admin-block" style={{ marginTop: '1.25rem' }}>
<h3>Recently connected</h3>
{data.recentPlayers?.length === 0 ? (
<p className="muted">No players yet.</p>
) : (
<ul className="admin-recent-list">
{data.recentPlayers.map((p) => (
<li key={p.id}>
<div className="admin-recent-avatar" aria-hidden>
{p.avatarUrl ? (
<img src={p.avatarUrl} alt="" />
) : (
<span>{p.username.slice(0, 2).toUpperCase()}</span>
)}
</div>
<div>
<strong>@{p.username}</strong>
<div className="muted">
{formatCredits(p.balance)} cr ·{' '}
{formatLastConnection(p.lastConnection)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
function dropPercents(items) {
const total = items.reduce((s, ci) => s + Number(ci.dropRate || 0), 0);
if (!(total > 0)) return items.map(() => 0);
return items.map((ci) => (Number(ci.dropRate) / total) * 100);
}
export default function AdminDrops() {
const [cases, setCases] = useState([]);
const [items, setItems] = useState([]);
const [error, setError] = useState('');
const [selectedCaseId, setSelectedCaseId] = useState('');
const [dropForm, setDropForm] = useState({ itemId: '', dropRate: 1 });
const load = useCallback(async () => {
const [c, i] = await Promise.all([api.adminCases(), api.adminItems()]);
setCases(c.cases);
setItems(i.items);
setSelectedCaseId((prev) => {
if (prev && c.cases.some((x) => String(x.id) === String(prev))) return prev;
return c.cases[0] ? String(c.cases[0].id) : '';
});
setDropForm((f) => ({
...f,
itemId: f.itemId || (i.items[0] ? String(i.items[0].id) : ''),
}));
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const selected = useMemo(
() => cases.find((c) => String(c.id) === String(selectedCaseId)) || null,
[cases, selectedCaseId]
);
const percents = useMemo(
() => (selected ? dropPercents(selected.items) : []),
[selected]
);
return (
<div>
<div className="section-head">
<div>
<h1>Drop rates</h1>
<p className="muted">Weights per case · % shown from relative weights</p>
</div>
<select
className="admin-search"
value={selectedCaseId}
onChange={(e) => setSelectedCaseId(e.target.value)}
aria-label="Select case"
>
{cases.map((c) => (
<option key={c.id} value={c.id}>
{c.name} ({c.items.length})
</option>
))}
</select>
</div>
{error && <div className="error">{error}</div>}
{!selected ? (
<div className="empty">Create a case first.</div>
) : (
<>
<div className="admin-block">
<h3>
Add drop · {selected.name}{' '}
<span className="muted">({formatCredits(selected.price)} cr)</span>
</h3>
<form
className="admin-row"
onSubmit={async (e) => {
e.preventDefault();
setError('');
try {
await api.addCaseItem(Number(selectedCaseId), {
itemId: Number(dropForm.itemId),
dropRate: Number(dropForm.dropRate),
});
await load();
} catch (err) {
setError(err.message);
}
}}
>
<select
value={dropForm.itemId}
onChange={(e) => setDropForm({ ...dropForm, itemId: e.target.value })}
>
{items.map((item) => (
<option key={item.id} value={item.id}>
{item.name} ({item.rarity})
</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>
<div className="admin-block">
<h3>Pool ({selected.items.length})</h3>
{selected.items.length === 0 ? (
<div className="muted">No drops yet.</div>
) : (
<div className="drop-list">
{selected.items.map((ci, idx) => (
<div key={ci.id} className="drop-row">
<span className="drop-item-cell">
{ci.item.imageUrl ? (
<img src={ci.item.imageUrl} alt="" className="drop-thumb" />
) : null}
{ci.item.name}{' '}
<span className="muted">({ci.item.rarity})</span>
</span>
<input
type="number"
step="0.1"
min="0.01"
defaultValue={ci.dropRate}
key={`${ci.id}-${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="admin-drop-pct">{percents[idx].toFixed(2)}%</span>
<button
type="button"
className="btn danger"
onClick={async () => {
await api.deleteCaseItem(ci.id);
await load();
}}
>
Remove
</button>
</div>
))}
</div>
)}
</div>
</>
)}
</div>
);
}
+370
View File
@@ -0,0 +1,370 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api, formatCredits } from '../api';
import ImageField from './ImageField';
const emptyItem = {
name: '',
rarity: 'MilSpec',
category: 'Misc',
marketValue: 100,
imageUrl: '',
};
function CategoryField({ value, onChange, categories, id }) {
const listId = id || 'item-categories';
return (
<>
<input
list={listId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder="e.g. Rifle, Knife…"
required
/>
<datalist id={listId}>
{categories.map((c) => (
<option key={c} value={c} />
))}
</datalist>
</>
);
}
export default function AdminItems() {
const [items, setItems] = useState([]);
const [rarities, setRarities] = useState([]);
const [categories, setCategories] = useState([]);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [rarityFilter, setRarityFilter] = useState('all');
const [categoryFilter, setCategoryFilter] = useState('all');
const [sort, setSort] = useState('category');
const [itemForm, setItemForm] = useState(emptyItem);
const [editingId, setEditingId] = useState(null);
const [edit, setEdit] = useState(null);
const load = useCallback(async () => {
const [i, r, c] = await Promise.all([
api.adminItems(),
api.adminRarities(),
api.adminCategories(),
]);
setItems(i.items);
setRarities(r.rarities);
setCategories(c.categories || []);
setItemForm((f) => ({
...f,
rarity: f.rarity || r.rarities[0] || 'MilSpec',
category: f.category || 'Misc',
}));
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const usedCategories = useMemo(() => {
const set = new Set(items.map((item) => item.category || 'Misc'));
return [...set].sort((a, b) => a.localeCompare(b));
}, [items]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
let list = items.filter((item) => {
const cat = item.category || 'Misc';
if (rarityFilter !== 'all' && item.rarity !== rarityFilter) return false;
if (categoryFilter !== 'all' && cat !== categoryFilter) return false;
if (!q) return true;
return (
item.name.toLowerCase().includes(q) ||
item.rarity.toLowerCase().includes(q) ||
cat.toLowerCase().includes(q)
);
});
list = [...list];
list.sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'value') return Number(b.marketValue) - Number(a.marketValue);
if (sort === 'rarity') return a.rarity.localeCompare(b.rarity);
// category (default): group by category, then name
const ca = (a.category || 'Misc').localeCompare(b.category || 'Misc');
if (ca !== 0) return ca;
return a.name.localeCompare(b.name);
});
return list;
}, [items, query, rarityFilter, categoryFilter, sort]);
return (
<div>
<div className="section-head">
<div>
<h1>Items</h1>
<p className="muted">
{items.length} skins · categories + Field-Tested reference values
</p>
</div>
<div className="admin-toolbar">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
aria-label="Filter category"
>
<option value="all">All categories</option>
{usedCategories.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
<select
value={rarityFilter}
onChange={(e) => setRarityFilter(e.target.value)}
aria-label="Filter rarity"
>
<option value="all">All rarities</option>
{rarities.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<select
value={sort}
onChange={(e) => setSort(e.target.value)}
aria-label="Sort items"
>
<option value="category">Sort: category</option>
<option value="name">Sort: name</option>
<option value="value">Sort: value</option>
<option value="rarity">Sort: rarity</option>
</select>
<input
className="admin-search"
placeholder="Search items…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="admin-block">
<h3>Create item</h3>
<form
className="form admin-form-grid"
onSubmit={async (e) => {
e.preventDefault();
setError('');
try {
await api.createItem({
...itemForm,
marketValue: Number(itemForm.marketValue),
});
setItemForm({
...emptyItem,
rarity: rarities[0] || 'MilSpec',
category: 'Misc',
});
await load();
} catch (err) {
setError(err.message);
}
}}
>
<label>
Name
<input
value={itemForm.name}
onChange={(e) => setItemForm({ ...itemForm, name: e.target.value })}
required
/>
</label>
<label>
Category
<CategoryField
id="create-item-categories"
value={itemForm.category}
categories={categories}
onChange={(category) => setItemForm({ ...itemForm, category })}
/>
</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, FT ref)
<input
type="number"
value={itemForm.marketValue}
onChange={(e) => setItemForm({ ...itemForm, marketValue: e.target.value })}
required
/>
</label>
<label className="admin-form-span">
Image
<ImageField
value={itemForm.imageUrl}
onChange={(imageUrl) => setItemForm({ ...itemForm, imageUrl })}
/>
</label>
<button className="btn" type="submit">
Add item
</button>
</form>
</div>
<p className="muted" style={{ marginBottom: '0.65rem' }}>
Showing {filtered.length} / {items.length}
</p>
<div className="admin-card-list">
{filtered.map((item) => (
<div key={item.id} className="admin-block">
{editingId === item.id && edit ? (
<form
className="form admin-form-grid"
onSubmit={async (e) => {
e.preventDefault();
setError('');
try {
await api.updateItem(editingId, {
name: edit.name,
rarity: edit.rarity,
category: edit.category,
marketValue: Number(edit.marketValue),
imageUrl: edit.imageUrl,
});
setEditingId(null);
setEdit(null);
await load();
} catch (err) {
setError(err.message);
}
}}
>
<h3 className="admin-form-span">Edit #{item.id}</h3>
<label>
Name
<input
value={edit.name}
onChange={(e) => setEdit({ ...edit, name: e.target.value })}
required
/>
</label>
<label>
Category
<CategoryField
id={`edit-item-categories-${item.id}`}
value={edit.category}
categories={categories}
onChange={(category) => setEdit({ ...edit, category })}
/>
</label>
<label>
Rarity
<select
value={edit.rarity}
onChange={(e) => setEdit({ ...edit, rarity: e.target.value })}
>
{rarities.map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
</label>
<label>
Value (cents)
<input
type="number"
value={edit.marketValue}
onChange={(e) => setEdit({ ...edit, marketValue: e.target.value })}
required
/>
</label>
<label className="admin-form-span">
Image
<ImageField
value={edit.imageUrl}
onChange={(imageUrl) => setEdit({ ...edit, imageUrl })}
/>
</label>
<div className="row-actions admin-form-span">
<button className="btn" type="submit">
Save
</button>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingId(null);
setEdit(null);
}}
>
Cancel
</button>
</div>
</form>
) : (
<div className="admin-row">
{item.imageUrl ? (
<div className="admin-thumb">
<img src={item.imageUrl} alt="" />
</div>
) : (
<div className="admin-thumb empty">?</div>
)}
<div className="admin-row-main">
<strong>{item.name}</strong>
<div className="muted">
<span className="admin-cat-pill">{item.category || 'Misc'}</span> ·{' '}
{item.rarity} · {formatCredits(item.marketValue)} cr
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingId(item.id);
setEdit({
name: item.name,
rarity: item.rarity,
category: item.category || 'Misc',
marketValue: item.marketValue,
imageUrl: item.imageUrl || '',
});
}}
>
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>
</div>
);
}
+54
View File
@@ -0,0 +1,54 @@
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { useAuth } from '../AuthContext';
const NAV = [
{ to: '/admin', end: true, label: 'Overview' },
{ to: '/admin/players', label: 'Players' },
{ to: '/admin/cases', label: 'Cases' },
{ to: '/admin/items', label: 'Items' },
{ to: '/admin/drops', label: 'Drops' },
];
export default function AdminLayout() {
const { user, logout } = useAuth();
const navigate = useNavigate();
return (
<div className="admin-shell">
<aside className="admin-sidebar">
<div className="admin-sidebar-brand">
<span className="brand">CaseForge</span>
<span className="admin-sidebar-tag">Admin</span>
</div>
<nav className="admin-side-nav">
{NAV.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.end}
className={({ isActive }) => (isActive ? 'active' : undefined)}
>
{item.label}
</NavLink>
))}
</nav>
<div className="admin-sidebar-foot">
<div className="muted admin-sidebar-user">@{user?.username}</div>
<button
type="button"
className="btn ghost"
onClick={async () => {
await logout();
navigate('/login');
}}
>
Logout
</button>
</div>
</aside>
<div className="admin-main">
<Outlet />
</div>
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
api,
formatCredits,
formatLastConnection,
formatPlayTime,
} from '../api';
export default function AdminPlayers() {
const [users, setUsers] = useState([]);
const [error, setError] = useState('');
const [query, setQuery] = useState('');
const [sort, setSort] = useState('wealth');
const [adjustId, setAdjustId] = useState(null);
const [deltaCredits, setDeltaCredits] = useState('100');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const data = await api.adminUsers();
setUsers(data.users);
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
let list = [...users];
if (q) {
list = list.filter((u) => u.username.toLowerCase().includes(q));
}
list.sort((a, b) => {
if (sort === 'name') return a.username.localeCompare(b.username);
if (sort === 'opens') return b.casesOpened - a.casesOpened;
if (sort === 'recent') {
return (
new Date(b.lastConnection || 0).getTime() -
new Date(a.lastConnection || 0).getTime()
);
}
return Number(b.totalWealth) - Number(a.totalWealth);
});
return list;
}, [users, query, sort]);
const applyDelta = async (userId, credits) => {
const cents = Math.round(Number(credits) * 100);
if (!Number.isInteger(cents) || cents === 0) {
setError('Enter a non-zero credit amount (e.g. 100 or -50)');
return;
}
setBusy(true);
setError('');
try {
await api.adminUpdateUser(userId, { balanceDelta: cents });
setAdjustId(null);
setDeltaCredits('100');
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
return (
<div>
<div className="section-head">
<div>
<h1>Players</h1>
<p className="muted">{users.length} accounts · adjust credits when needed</p>
</div>
<div className="admin-toolbar">
<select value={sort} onChange={(e) => setSort(e.target.value)} aria-label="Sort">
<option value="wealth">Sort: wealth</option>
<option value="opens">Sort: opens</option>
<option value="recent">Sort: last seen</option>
<option value="name">Sort: name</option>
</select>
<input
className="admin-search"
placeholder="Search players…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
</div>
{error && <div className="error">{error}</div>}
<div className="admin-card-list">
{filtered.map((u) => (
<div key={u.id} className="admin-block admin-player-card">
<div className="admin-row">
<div className="admin-thumb">
{u.avatarUrl ? (
<img src={u.avatarUrl} alt="" />
) : (
<span>{u.username.slice(0, 2).toUpperCase()}</span>
)}
</div>
<div className="admin-row-main">
<strong>@{u.username}</strong>
<div className="muted">
Wealth {formatCredits(u.totalWealth)} cr · bal{' '}
{formatCredits(u.balance)} · inv {formatCredits(u.inventoryValue)} (
{u.inventoryCount})
</div>
<div className="muted">
Opens {u.casesOpened} · prestige {u.prestigeCount} · achievements{' '}
{u.achievements} · play {formatPlayTime(u.playTimeSeconds)} · last{' '}
{formatLastConnection(u.lastConnection)}
</div>
</div>
<button
type="button"
className="btn secondary"
onClick={() => {
setAdjustId(adjustId === u.id ? null : u.id);
setDeltaCredits('100');
}}
>
Adjust credits
</button>
</div>
{adjustId === u.id && (
<form
className="admin-adjust-row"
onSubmit={(e) => {
e.preventDefault();
applyDelta(u.id, deltaCredits);
}}
>
<label>
Δ credits (e.g. 100 or -50)
<input
type="number"
step="0.01"
value={deltaCredits}
onChange={(e) => setDeltaCredits(e.target.value)}
required
/>
</label>
<button className="btn" type="submit" disabled={busy}>
Apply
</button>
<button
type="button"
className="btn ghost"
onClick={() => applyDelta(u.id, '1000')}
disabled={busy}
>
+1000
</button>
<button
type="button"
className="btn ghost"
onClick={() => applyDelta(u.id, '-100')}
disabled={busy}
>
100
</button>
</form>
)}
</div>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { useState } from 'react';
import { api } from '../api';
export default 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>
);
}
+12 -3
View File
@@ -34,10 +34,11 @@ export const api = {
case: (id) => request(`/api/cases/${id}`),
catalog: () => request('/api/catalog'),
prestige: () => request('/api/prestige'),
doPrestige: (choice) =>
request('/api/prestige', {
doPrestige: () => request('/api/prestige', { method: 'POST', body: JSON.stringify({}) }),
prestigeUpgrade: (skillId) =>
request('/api/prestige/upgrade', {
method: 'POST',
body: JSON.stringify({ choice }),
body: JSON.stringify({ skillId }),
}),
openCase: (id, count = 1) =>
request(`/api/cases/${id}/open`, {
@@ -127,9 +128,17 @@ export const api = {
method: 'POST',
body: JSON.stringify({ username, password }),
}),
adminStats: () => request('/api/admin/stats'),
adminUsers: () => request('/api/admin/users'),
adminUpdateUser: (id, body) =>
request(`/api/admin/users/${id}`, {
method: 'PATCH',
body: JSON.stringify(body),
}),
adminCases: () => request('/api/admin/cases'),
adminItems: () => request('/api/admin/items'),
adminRarities: () => request('/api/admin/rarities'),
adminCategories: () => request('/api/admin/categories'),
createCase: (body) =>
request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }),
updateCase: (id, body) =>
@@ -1,3 +1,5 @@
import { formatCredits } from '../api';
export default function ProfilePrestigeStats({ prestige }) {
if (!prestige || !prestige.count) return null;
@@ -8,6 +10,11 @@ export default function ProfilePrestigeStats({ prestige }) {
<span className="catalog-badges-luck">×{prestige.count}</span>
</div>
<ul className="stat-list prestige-profile-stats">
{typeof prestige.prBalance === 'number' && prestige.prBalance > 0 && (
<li>
Pr <strong>{prestige.prBalance}</strong>
</li>
)}
<li>
Chance <strong>+{prestige.chanceBonus}%</strong>
</li>
@@ -17,6 +24,51 @@ export default function ProfilePrestigeStats({ prestige }) {
<li>
Key opens <strong>{prestige.keyOpenReduction}</strong>
</li>
{(prestige.startBonus || 0) > 0 && (
<li>
Vault <strong>{formatCredits(prestige.startBonus)} cr</strong>
</li>
)}
{(prestige.adBonus || 0) > 0 && (
<li>
Ad reward <strong>+{prestige.adBonus}%</strong>
</li>
)}
{(prestige.adCooldownReduction || 0) > 0 && (
<li>
Ad cooldown <strong>{prestige.adCooldownReduction}s</strong>
</li>
)}
{(prestige.wearBonus || 0) > 0 && (
<li>
Polish <strong>+{prestige.wearBonus}</strong>
</li>
)}
{(prestige.prBonus || 0) > 0 && (
<li>
Pr echo <strong>+{prestige.prBonus}%</strong>
</li>
)}
{(prestige.shopDiscount || 0) > 0 && (
<li>
Haggle <strong>{prestige.shopDiscount} osu</strong>
</li>
)}
{(prestige.startOsu || 0) > 0 && (
<li>
Purse <strong>+{prestige.startOsu} osu</strong>
</li>
)}
{(prestige.dropValueBonus || 0) > 0 && (
<li>
Magnet <strong>+{prestige.dropValueBonus}%</strong>
</li>
)}
{(prestige.openBonus || 0) > 0 && (
<li>
Bulk <strong>+{prestige.openBonus} opens</strong>
</li>
)}
</ul>
</div>
);
+545 -53
View File
@@ -1431,41 +1431,304 @@ a.balance-pill-link.active {
}
.admin-tabs button {
background: transparent;
border: 1px solid var(--stroke);
color: var(--muted);
padding: 0.5rem 0.9rem;
background: var(--bg-panel);
color: var(--text);
padding: 0.45rem 0.85rem;
cursor: pointer;
font-family: var(--font-display);
}
.admin-tabs button.active {
border-color: var(--accent);
color: var(--accent);
}
/* —— Admin shell —— */
.admin-shell {
display: grid;
grid-template-columns: 13.5rem 1fr;
min-height: 100vh;
background: var(--bg-deep);
}
.admin-sidebar {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.15rem 0.85rem;
border-right: 1px solid var(--stroke);
background: var(--bg-panel);
}
.admin-sidebar-brand {
display: flex;
flex-direction: column;
gap: 0.15rem;
padding: 0 0.35rem 0.5rem;
border-bottom: 1px solid var(--stroke);
}
.admin-sidebar-tag {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--accent);
font-family: var(--font-display);
}
.admin-side-nav {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
}
.admin-side-nav a {
padding: 0.55rem 0.65rem;
border: 1px solid transparent;
font-family: var(--font-display);
font-size: 0.92rem;
color: var(--muted);
}
.admin-side-nav a:hover {
color: var(--text);
border-color: var(--accent-dim);
background: rgba(61, 214, 198, 0.08);
background: rgba(61, 214, 198, 0.05);
}
.admin-side-nav a.active {
color: var(--accent);
border-color: color-mix(in srgb, var(--accent) 35%, var(--stroke));
background: color-mix(in srgb, var(--accent) 10%, transparent);
}
.admin-sidebar-foot {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding-top: 0.75rem;
border-top: 1px solid var(--stroke);
}
.admin-sidebar-user {
font-size: 0.8rem;
padding: 0 0.35rem;
}
.admin-main {
padding: 1.35rem 1.5rem 2.5rem;
max-width: 1100px;
width: 100%;
}
.admin-stat-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
gap: 0.75rem;
margin-bottom: 1.15rem;
}
.admin-stat {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.9rem 0.95rem;
border: 1px solid var(--stroke);
background: var(--bg-panel);
}
.admin-stat-label {
font-size: 0.68rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
font-family: var(--font-display);
}
.admin-stat strong {
font-family: var(--font-display);
font-size: 1.45rem;
font-weight: 600;
}
.admin-stat .muted {
font-size: 0.75rem;
}
.admin-quick-links {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.admin-recent-list {
list-style: none;
margin: 0.75rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.admin-recent-list li {
display: flex;
align-items: center;
gap: 0.75rem;
}
.admin-recent-avatar {
width: 2.4rem;
height: 2.4rem;
display: grid;
place-items: center;
overflow: hidden;
border: 1px solid var(--stroke);
background: var(--bg-deep);
font-family: var(--font-display);
font-size: 0.7rem;
color: var(--muted);
}
.admin-recent-avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.admin-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.admin-search {
min-width: 11rem;
padding: 0.5rem 0.65rem;
border: 1px solid var(--stroke);
background: var(--bg-deep);
color: var(--text);
}
.admin-form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem 1rem;
}
.admin-form-grid .admin-form-span {
grid-column: 1 / -1;
}
.admin-form-grid .btn {
justify-self: start;
}
.admin-row-main {
flex: 1;
min-width: 0;
}
.admin-pill-on {
color: var(--accent);
}
.admin-pill-off {
color: var(--danger);
}
.admin-cat-pill {
display: inline-block;
font-family: var(--font-display);
font-size: 0.72rem;
letter-spacing: 0.04em;
color: var(--accent);
border: 1px solid color-mix(in srgb, var(--accent) 35%, var(--stroke));
padding: 0.1rem 0.4rem;
}
.admin-drop-pct {
font-family: var(--font-display);
font-size: 0.85rem;
color: var(--accent);
min-width: 4.2rem;
text-align: right;
}
.admin-adjust-row {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
align-items: end;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--stroke);
}
.admin-adjust-row label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--muted);
}
.admin-adjust-row input {
padding: 0.45rem 0.55rem;
border: 1px solid var(--stroke);
background: var(--bg-deep);
color: var(--text);
min-width: 8rem;
}
.admin-card-list {
display: flex;
flex-direction: column;
gap: 0.65rem;
}
@media (max-width: 820px) {
.admin-shell {
grid-template-columns: 1fr;
}
.admin-sidebar {
border-right: none;
border-bottom: 1px solid var(--stroke);
}
.admin-side-nav {
flex-direction: row;
flex-wrap: wrap;
}
.admin-form-grid {
grid-template-columns: 1fr;
}
}
.admin-block {
border: 1px solid var(--stroke);
background: var(--bg-panel);
padding: 1rem;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
.admin-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
gap: 0.65rem;
align-items: center;
margin-bottom: 0.65rem;
}
.admin-row input,
.admin-row select {
background: var(--bg-deep);
border: 1px solid var(--stroke);
color: var(--text);
padding: 0.45rem 0.55rem;
border: 1px solid var(--stroke);
background: var(--bg-deep);
color: var(--text);
}
.drop-list {
@@ -2629,6 +2892,72 @@ a.achievement-badge:hover .achievement-badge-name {
/* Prestige */
.prestige-head {
align-items: flex-start;
margin-bottom: 1.25rem;
}
.prestige-head-copy h1 {
margin-bottom: 0.35rem;
}
.prestige-lead {
margin: 0;
max-width: 36rem;
line-height: 1.45;
color: var(--muted);
font-size: 0.98rem;
}
.prestige-lead strong {
color: var(--gold);
font-weight: 600;
}
.prestige-steps {
list-style: none;
margin: 0 0 1.5rem;
padding: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.prestige-steps > li {
display: flex;
gap: 0.7rem;
align-items: flex-start;
padding: 0.85rem 0.95rem;
border: 1px solid var(--stroke);
background: color-mix(in srgb, var(--bg-panel) 88%, transparent);
}
.prestige-steps strong {
display: block;
margin-bottom: 0.2rem;
font-family: var(--font-display);
font-size: 0.95rem;
}
.prestige-steps p {
margin: 0;
font-size: 0.82rem;
line-height: 1.35;
}
.prestige-step-num {
flex-shrink: 0;
width: 1.55rem;
height: 1.55rem;
display: grid;
place-items: center;
border: 1px solid color-mix(in srgb, var(--gold) 50%, var(--stroke));
background: color-mix(in srgb, var(--gold) 12%, var(--bg-deep));
color: var(--gold);
font-family: var(--font-display);
font-size: 0.85rem;
}
.prestige-count-pill {
font-family: var(--font-display);
color: var(--gold);
@@ -2638,6 +2967,32 @@ a.achievement-badge:hover .achievement-badge-name {
white-space: nowrap;
}
.prestige-pills {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.prestige-pr-pill {
font-family: var(--font-display);
border: 1px solid color-mix(in srgb, var(--gold) 45%, var(--stroke));
padding: 0.35rem 0.7rem;
background: color-mix(in srgb, var(--gold) 10%, var(--bg-panel));
color: var(--gold);
white-space: nowrap;
}
.prestige-pr-est {
color: var(--gold);
}
.prestige-pr-hint {
margin: 0;
font-size: 0.82rem;
line-height: 1.4;
}
.prestige-totals {
display: grid;
grid-template-columns: repeat(3, minmax(0, 10rem));
@@ -2645,6 +3000,10 @@ a.achievement-badge:hover .achievement-badge-name {
margin-bottom: 1.25rem;
}
.prestige-totals-wide {
grid-template-columns: repeat(auto-fill, minmax(7.5rem, 1fr));
}
.prestige-totals > div {
display: flex;
flex-direction: column;
@@ -2658,56 +3017,192 @@ a.achievement-badge:hover .achievement-badge-name {
color: var(--text);
}
.prestige-cost-row {
.prestige-action {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 40rem;
margin-bottom: 1.75rem;
padding: 1.15rem 1.2rem;
}
.prestige-action-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.5rem 1rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.prestige-choices {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 0.85rem;
margin-bottom: 1.25rem;
.prestige-action-head h2 {
margin: 0;
font-size: 1.15rem;
}
.prestige-choice {
text-align: left;
background: linear-gradient(160deg, var(--bg-elevated), var(--bg-panel));
.prestige-status {
font-size: 0.85rem;
color: var(--muted);
border: 1px solid var(--stroke);
color: inherit;
padding: 1rem;
cursor: pointer;
font: inherit;
padding: 0.25rem 0.55rem;
}
.prestige-choice:disabled {
opacity: 0.55;
cursor: not-allowed;
.prestige-status.ready {
color: var(--gold);
border-color: color-mix(in srgb, var(--gold) 45%, var(--stroke));
background: color-mix(in srgb, var(--gold) 8%, transparent);
}
.prestige-choice.selected {
border-color: rgba(228, 174, 57, 0.55);
box-shadow: inset 0 0 0 1px rgba(228, 174, 57, 0.25);
.prestige-action-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
}
.prestige-choice h2 {
margin: 0 0 0.4rem;
.prestige-action-stats > div {
display: flex;
flex-direction: column;
gap: 0.2rem;
padding: 0.65rem 0.75rem;
background: var(--bg-deep);
border: 1px solid var(--stroke);
font-size: 0.82rem;
}
.prestige-action-stats strong {
font-family: var(--font-display);
font-size: 1.05rem;
}
.prestige-choice p {
margin: 0 0 0.75rem;
font-size: 0.85rem;
.prestige-action-split {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.85rem;
}
.prestige-choice-meta {
.prestige-action-split h3 {
margin: 0 0 0.4rem;
font-size: 0.78rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
font-weight: 600;
}
.prestige-action-split ul {
margin: 0;
padding: 0 0 0 1.05rem;
display: flex;
justify-content: space-between;
gap: 0.5rem;
flex-direction: column;
gap: 0.3rem;
font-size: 0.88rem;
line-height: 1.35;
}
.prestige-action-btn {
align-self: flex-start;
min-width: 10rem;
}
.prestige-action .btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
@media (max-width: 720px) {
.prestige-steps {
grid-template-columns: 1fr;
}
.prestige-action-stats,
.prestige-action-split {
grid-template-columns: 1fr;
}
}
.prestige-tree-section {
margin-top: 0.5rem;
}
.prestige-tree-section h2 {
margin: 0 0 0.25rem;
}
.prestige-tree {
display: flex;
flex-direction: column;
align-items: center;
margin-top: 1rem;
}
.prestige-tree-root {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.15rem;
padding: 0.85rem 1.25rem;
border: 1px solid color-mix(in srgb, var(--gold) 50%, var(--stroke));
background:
linear-gradient(165deg, rgba(228, 174, 57, 0.12) 0%, transparent 55%),
var(--bg-panel);
font-family: var(--font-display);
}
.prestige-tree-root span {
font-size: 0.7rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--muted);
}
.prestige-tree-root strong {
color: var(--gold);
font-size: 1.15rem;
}
.prestige-tree-branches {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(12.5rem, 1fr));
gap: 0.75rem;
width: 100%;
max-width: 56rem;
}
.prestige-tree-branch {
display: flex;
flex-direction: column;
align-items: center;
}
.prestige-tree-connector {
width: 2px;
height: 1.25rem;
background: color-mix(in srgb, var(--gold) 40%, var(--stroke));
}
.prestige-skill-node {
width: 100%;
padding: 0.95rem 0.85rem;
border: 1px solid var(--stroke);
background: var(--bg-panel);
text-align: center;
}
.prestige-skill-node h3 {
margin: 0 0 0.35rem;
font-family: var(--font-display);
font-size: 1.05rem;
}
.prestige-skill-node p {
margin: 0 0 0.65rem;
font-size: 0.82rem;
}
.prestige-skill-meta {
display: flex;
flex-direction: column;
gap: 0.2rem;
margin-bottom: 0.75rem;
font-size: 0.8rem;
}
@@ -2716,19 +3211,6 @@ a.achievement-badge:hover .achievement-badge-name {
font-family: var(--font-display);
}
.prestige-confirm {
display: flex;
flex-direction: column;
gap: 0.75rem;
max-width: 32rem;
}
.prestige-confirm label {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.prestige-history {
margin-top: 1.5rem;
padding-top: 1rem;
@@ -2749,3 +3231,13 @@ a.achievement-badge:hover .achievement-badge-name {
.prestige-profile-stats {
margin-top: 0.35rem;
}
@media (max-width: 720px) {
.prestige-tree-branches {
grid-template-columns: 1fr;
}
.prestige-totals {
grid-template-columns: 1fr;
}
}
+59 -632
View File
@@ -1,652 +1,79 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useState } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { useAuth } from '../AuthContext';
import AdminLayout from '../admin/AdminLayout';
import AdminDashboard from '../admin/AdminDashboard';
import AdminCases from '../admin/AdminCases';
import AdminItems from '../admin/AdminItems';
import AdminDrops from '../admin/AdminDrops';
import AdminPlayers from '../admin/AdminPlayers';
const emptyCase = { name: '', price: 100, imageUrl: '', active: true };
const emptyItem = { name: '', rarity: 'MilSpec', marketValue: 100, imageUrl: '' };
function AdminLogin() {
const { adminLogin } = useAuth();
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
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);
const onSubmit = async (e) => {
e.preventDefault();
setError('');
setBusy(true);
try {
const data = await api.adminUpload(file);
onChange(data.url);
await adminLogin(username, password);
} catch (err) {
alert(err.message);
setError(err.message);
} finally {
setUploading(false);
e.target.value = '';
setBusy(false);
}
};
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 className="auth-page">
<div className="auth-panel">
<div className="brand">CaseForge</div>
<h1>Admin</h1>
<p className="muted">Cases, items, drops, players & overview.</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>
);
}
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 { user, loading } = useAuth();
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 () => {
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) {
setItemForm((f) => ({ ...f, rarity: 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>
);
}
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);
}
};
if (loading) return <p className="muted" style={{ padding: '2rem' }}>Loading</p>;
if (!user || user.role !== 'admin') return <AdminLogin />;
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, images, 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="form"
onSubmit={async (e) => {
e.preventDefault();
try {
await api.createCase({
...caseForm,
price: Number(caseForm.price),
});
setCaseForm(emptyCase);
await load();
} catch (err) {
setError(err.message);
}
}}
>
<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 case
</button>
</form>
</div>
{cases.map((c) => (
<div key={c.id} className="admin-block">
{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>
)}
{tab === 'items' && (
<div>
<div className="admin-block">
<h3>Create item</h3>
<form
className="form"
onSubmit={async (e) => {
e.preventDefault();
try {
await api.createItem({
...itemForm,
marketValue: Number(itemForm.marketValue),
});
setItemForm({
...emptyItem,
rarity: rarities[0] || 'MilSpec',
});
await load();
} catch (err) {
setError(err.message);
}
}}
>
<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, Field-Tested ref)
<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 item
</button>
</form>
</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, Field-Tested ref)
<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>
)}
{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 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
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>
<Routes>
<Route element={<AdminLayout />}>
<Route index element={<AdminDashboard />} />
<Route path="players" element={<AdminPlayers />} />
<Route path="cases" element={<AdminCases />} />
<Route path="items" element={<AdminItems />} />
<Route path="drops" element={<AdminDrops />} />
<Route path="*" element={<Navigate to="/admin" replace />} />
</Route>
</Routes>
);
}
+230 -80
View File
@@ -3,17 +3,22 @@ import { Navigate } from 'react-router-dom';
import { api, formatCredits } from '../api';
import { useAuth } from '../AuthContext';
const CHOICE_LABELS = {
chance: 'Chance',
gain: 'Gain',
key: 'Key progress',
};
function formatSkillValue(skill, value) {
const prefix = skill.prefix || '';
if (skill.unit === 'cr-cents') {
return `${prefix}${formatCredits(value)} cr`.replace(/^\+\+/, '+');
}
if (skill.unit === '%') return `${prefix || '+'}${value}%`;
if (skill.unit === 's') return `${prefix || ''}${value}s`;
if (skill.unit === ' osu') return `${prefix || '+'}${value} osu`;
if (skill.prefix === '') return `${value}`;
if (value === 0 && !prefix) return `${value}`;
return `${prefix || '+'}${value}`;
}
export default function PrestigePage() {
const { user, loading, patchUser, refresh } = useAuth();
const [data, setData] = useState(null);
const [selected, setSelected] = useState(null);
const [confirmText, setConfirmText] = useState('');
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [busy, setBusy] = useState(false);
@@ -49,22 +54,21 @@ export default function PrestigePage() {
const cost = data?.prestige?.costCents ?? 0;
const p = data?.prestige;
const unlockAt = data?.prestige?.visibleAtCents ?? 0;
const prBalance = p?.prBalance ?? user.prBalance ?? 0;
const canClick = Boolean(data?.canPrestige) && !busy;
const onPrestige = async () => {
if (!selected || confirmText !== 'PRESTIGE') return;
if (!canClick) return;
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.doPrestige(selected);
const result = await api.doPrestige();
patchUser(result.user);
await refresh();
setMessage(
`Prestige #${result.prestige.count} complete · chose ${CHOICE_LABELS[result.choice] || result.choice}. Account reset.`
`Prestige #${result.prestige.count} · +${result.prGranted} Pr. Balance, inventory, keys & catalog reset.`
);
setSelected(null);
setConfirmText('');
await load();
} catch (err) {
setError(err.message);
@@ -73,28 +77,90 @@ export default function PrestigePage() {
}
};
const onUpgrade = async (skillId) => {
setBusy(true);
setError('');
setMessage('');
try {
const result = await api.prestigeUpgrade(skillId);
patchUser(result.user);
setMessage(`Upgraded ${skillId}.`);
setData((prev) =>
prev
? {
...prev,
prestige: result.prestige,
skills: result.skills,
}
: prev
);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
};
const shortfall = Math.max(0, cost - (Number(user.balance) || 0));
const statusLabel = !data?.canAfford
? `Need ${formatCredits(shortfall)} cr more`
: data?.lockedItems > 0
? `Withdraw ${data.lockedItems} locked item${data.lockedItems === 1 ? '' : 's'} first`
: `Ready · ~${data.previewPr} Pr`;
return (
<section className="section">
<div className="section-head">
<div>
<section className="section prestige-page">
<div className="section-head prestige-head">
<div className="prestige-head-copy">
<h1>Prestige</h1>
<p className="muted">
Pay {formatCredits(cost)} cr, pick one permanent boost, then reset balance, inventory,
keys, and catalog. History is kept. Unlocks at {formatCredits(unlockAt)} cr total wealth.
<p className="prestige-lead">
Reset your progress for <strong className="gold">Pr</strong>, then spend Pr on permanent
skills.
</p>
</div>
{p && (
<div className="prestige-count-pill">
Prestige <strong>{p.count}</strong>
<div className="prestige-pills">
{p && (
<div className="prestige-count-pill">
Prestige <strong>{p.count}</strong>
</div>
)}
<div className="prestige-pr-pill">
<strong>{prBalance}</strong> Pr
</div>
)}
</div>
</div>
<ol className="prestige-steps">
<li>
<span className="prestige-step-num">1</span>
<div>
<strong>Pay the cost</strong>
<p className="muted">{formatCredits(cost)} cr from your balance</p>
</div>
</li>
<li>
<span className="prestige-step-num">2</span>
<div>
<strong>Earn Pr</strong>
<p className="muted">
+{p?.prBaseReward ?? 1000} base, more if you are over the cost
</p>
</div>
</li>
<li>
<span className="prestige-step-num">3</span>
<div>
<strong>Upgrade skills</strong>
<p className="muted">Permanent bonuses they survive every reset</p>
</div>
</li>
</ol>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
{p && (
<div className="prestige-totals">
<div className="prestige-totals prestige-totals-wide">
<div>
<span className="muted">Chance</span>
<strong>+{p.chanceBonus}%</strong>
@@ -107,19 +173,45 @@ export default function PrestigePage() {
<span className="muted">Key opens</span>
<strong>{p.keyOpenReduction}</strong>
</div>
<div>
<span className="muted">Vault</span>
<strong>{formatCredits(p.startBonus || 0)} cr</strong>
</div>
<div>
<span className="muted">Ad reward</span>
<strong>+{p.adBonus || 0}%</strong>
</div>
<div>
<span className="muted">Ad cooldown</span>
<strong>{p.adCooldownReduction || 0}s</strong>
</div>
<div>
<span className="muted">Polish</span>
<strong>+{p.wearBonus || 0}</strong>
</div>
<div>
<span className="muted">Pr echo</span>
<strong>+{p.prBonus || 0}%</strong>
</div>
<div>
<span className="muted">Haggle</span>
<strong>{p.shopDiscount || 0} osu</strong>
</div>
<div>
<span className="muted">Purse</span>
<strong>+{p.startOsu || 0} osu</strong>
</div>
<div>
<span className="muted">Magnet</span>
<strong>+{p.dropValueBonus || 0}%</strong>
</div>
<div>
<span className="muted">Bulk</span>
<strong>+{p.openBonus || 0}</strong>
</div>
</div>
)}
<div className="prestige-cost-row">
<span className="muted">Cost</span>
<strong className="gold">{formatCredits(cost)} cr</strong>
<span className="muted">Your balance</span>
<strong>{formatCredits(user.balance)} cr</strong>
</div>
{!data?.canAfford && (
<p className="muted">You need {formatCredits(cost)} cr to prestige.</p>
)}
{data?.lockedItems > 0 && (
<div className="error">
Withdraw {data.lockedItems} locked item{data.lockedItems === 1 ? '' : 's'} from battles
@@ -127,68 +219,126 @@ export default function PrestigePage() {
</div>
)}
<div className="prestige-choices">
{(data?.choices || []).map((c) => (
<button
key={c.id}
type="button"
className={`prestige-choice${selected === c.id ? ' selected' : ''}`}
onClick={() => setSelected(c.id)}
disabled={busy || !data?.canPrestige}
>
<h2>{c.title}</h2>
<p className="muted">{c.description}</p>
<div className="prestige-choice-meta">
<span>
Now <strong>+{c.current}{c.id === 'key' ? '' : '%'}</strong>
{c.id === 'key' ? ' opens reduced' : ''}
</span>
<span className="prestige-delta">
+{c.current + c.delta}
{c.id === 'key' ? '' : '%'}
</span>
</div>
</button>
))}
</div>
<div className="prestige-action admin-block">
<div className="prestige-action-head">
<h2>Reset now</h2>
<span className={`prestige-status${data?.canPrestige ? ' ready' : ''}`}>
{statusLabel}
</span>
</div>
<div className="prestige-confirm admin-block">
<p>
This permanently spends {formatCredits(cost)} cr and wipes your balance, inventory, case
keys, and catalog. Type <strong>PRESTIGE</strong> to confirm.
<div className="prestige-action-stats">
<div>
<span className="muted">Cost</span>
<strong className="gold">{formatCredits(cost)} cr</strong>
</div>
<div>
<span className="muted">Your balance</span>
<strong>{formatCredits(user.balance)} cr</strong>
</div>
<div>
<span className="muted">Est. Pr reward</span>
<strong className="prestige-pr-est">
{data?.canAfford ? `~${data.previewPr} Pr` : '—'}
</strong>
</div>
</div>
<div className="prestige-action-split">
<div>
<h3>You keep</h3>
<ul>
<li>All Pr already earned</li>
<li>Skill-tree bonuses</li>
<li>Account &amp; prestige count</li>
</ul>
</div>
<div>
<h3>You lose</h3>
<ul>
<li>Balance (minus Vault / Purse)</li>
<li>Inventory</li>
<li>Case keys &amp; catalog progress</li>
</ul>
</div>
</div>
<p className="muted prestige-pr-hint">
Reward: +{p?.prBaseReward ?? 1000} Pr base. Every{' '}
{formatCredits(p?.excessChunkCents ?? 0)} cr above the cost adds ~{p?.prPerExcessChunk ?? 1000}{' '}
Pr.
</p>
<label>
Confirmation
<input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder="PRESTIGE"
disabled={busy || !selected}
autoComplete="off"
/>
</label>
<button
type="button"
className="btn"
disabled={
busy || !data?.canPrestige || !selected || confirmText !== 'PRESTIGE'
}
className="btn prestige-action-btn"
disabled={!canClick}
onClick={onPrestige}
title={
!data?.canAfford
? `Need ${formatCredits(cost)} cr`
: data?.lockedItems > 0
? 'Withdraw locked items first'
: 'Prestige now'
}
>
{busy ? 'Prestiging…' : 'Prestige now'}
</button>
</div>
<div className="prestige-tree-section">
<div className="section-head">
<div>
<h2>Skill tree</h2>
<p className="muted">Spend Pr on permanent branches. Costs rise with each purchase.</p>
</div>
</div>
<div className="prestige-tree">
<div className="prestige-tree-root" aria-hidden>
<span>Core</span>
<strong>{prBalance} Pr</strong>
</div>
<div className="prestige-tree-branches">
{(data?.skills || []).map((skill) => (
<div key={skill.id} className="prestige-tree-branch">
<div className="prestige-tree-connector" aria-hidden />
<article className="prestige-skill-node">
<h3>{skill.title}</h3>
<p className="muted">{skill.description}</p>
<div className="prestige-skill-meta">
<span>
Lv {skill.level} · now{' '}
<strong>{formatSkillValue(skill, skill.current)}</strong>
</span>
<span className="prestige-delta">
{formatSkillValue(skill, skill.nextValue)}
</span>
</div>
<button
type="button"
className="btn secondary"
disabled={busy || !skill.canAfford}
onClick={() => onUpgrade(skill.id)}
>
Buy · {skill.cost} Pr
</button>
</article>
</div>
))}
</div>
</div>
</div>
{data?.history?.length > 0 && (
<div className="prestige-history">
<h2>History</h2>
<ul className="stat-list">
{data.history.map((h) => (
<li key={h.id}>
{CHOICE_LABELS[h.choice] || h.choice}{' '}
<span className="muted">
· {new Date(h.createdAt).toLocaleString()}
</span>
Prestige reset
{h.prGranted > 0 ? ` · +${h.prGranted} Pr` : ''}{' '}
<span className="muted">· {new Date(h.createdAt).toLocaleString()}</span>
</li>
))}
</ul>