This commit is contained in:
gpatruno
2026-07-18 17:40:39 +02:00
parent 2168998679
commit d0f868ffa5
19 changed files with 406 additions and 28 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# CaseForge # CaseOrion
CS-style case opening site with virtual credits, inventory, multiplayer item betting, shop, leaderboard, and an admin panel for cases / drops / rates. CS-style case opening site with virtual credits, inventory, multiplayer item betting, shop, leaderboard, and an admin panel for cases / drops / rates.
+1 -1
View File
@@ -12,7 +12,7 @@
href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap" href="https://fonts.googleapis.com/css2?family=Chakra+Petch:wght@500;600;700&family=IBM+Plex+Sans:wght@400;500;600&display=swap"
rel="stylesheet" rel="stylesheet"
/> />
<title>CaseForge</title> <title>CaseOrion</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+192
View File
@@ -0,0 +1,192 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { api } from '../api';
export default function AdminCategories() {
const [categories, setCategories] = useState([]);
const [error, setError] = useState('');
const [message, setMessage] = useState('');
const [query, setQuery] = useState('');
const [name, setName] = useState('');
const [editingId, setEditingId] = useState(null);
const [editName, setEditName] = useState('');
const [busy, setBusy] = useState(false);
const load = useCallback(async () => {
const data = await api.adminCategories();
setCategories(data.categories || []);
}, []);
useEffect(() => {
load().catch((err) => setError(err.message));
}, [load]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return categories;
return categories.filter((c) => c.name.toLowerCase().includes(q));
}, [categories, query]);
return (
<div>
<div className="section-head">
<div>
<h1>Categories</h1>
<p className="muted">
{categories.length} categories · rename updates items · delete only when empty
</p>
</div>
<input
className="admin-search"
placeholder="Search categories…"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
{error && <div className="error">{error}</div>}
{message && <div className="ok-msg">{message}</div>}
<div className="admin-block">
<h3>Create category</h3>
<form
className="form admin-row"
onSubmit={async (e) => {
e.preventDefault();
setError('');
setMessage('');
setBusy(true);
try {
await api.createCategory({ name });
setName('');
setMessage('Category created.');
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}}
>
<label>
Name
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Rifle, Charm…"
required
/>
</label>
<button className="btn" type="submit" disabled={busy || !name.trim()}>
{busy ? 'Creating…' : 'Create'}
</button>
</form>
</div>
<div className="admin-block">
<h3>All categories</h3>
{filtered.length === 0 ? (
<p className="muted">No categories match.</p>
) : (
<ul className="admin-cat-list">
{filtered.map((cat) => (
<li key={cat.id} className="admin-cat-row">
{editingId === cat.id ? (
<form
className="admin-row admin-cat-edit"
onSubmit={async (e) => {
e.preventDefault();
setError('');
setMessage('');
setBusy(true);
try {
await api.updateCategory(cat.id, { name: editName });
setEditingId(null);
setEditName('');
setMessage('Category renamed.');
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}}
>
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
required
autoFocus
/>
<button className="btn" type="submit" disabled={busy}>
Save
</button>
<button
type="button"
className="btn ghost"
onClick={() => {
setEditingId(null);
setEditName('');
}}
>
Cancel
</button>
</form>
) : (
<>
<div className="admin-row-main">
<strong>{cat.name}</strong>
<span className="muted">
{cat.itemCount} item{cat.itemCount === 1 ? '' : 's'}
</span>
</div>
<div className="admin-row">
<button
type="button"
className="btn secondary"
onClick={() => {
setEditingId(cat.id);
setEditName(cat.name);
setError('');
setMessage('');
}}
>
Rename
</button>
<button
type="button"
className="btn ghost"
disabled={cat.itemCount > 0 || busy}
title={
cat.itemCount > 0
? `Used by ${cat.itemCount} item${cat.itemCount === 1 ? '' : 's'}`
: 'Delete category'
}
onClick={async () => {
if (cat.itemCount > 0) return;
setError('');
setMessage('');
setBusy(true);
try {
await api.deleteCategory(cat.id);
setMessage(`Deleted “${cat.name}”.`);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}}
>
Delete
</button>
</div>
</>
)}
</li>
))}
</ul>
)}
</div>
</div>
);
}
+1 -1
View File
@@ -31,7 +31,7 @@ export default function AdminDashboard() {
<div className="section-head"> <div className="section-head">
<div> <div>
<h1>Overview</h1> <h1>Overview</h1>
<p className="muted">Live snapshot of CaseForge.</p> <p className="muted">Live snapshot of CaseOrion.</p>
</div> </div>
</div> </div>
+5 -1
View File
@@ -51,7 +51,11 @@ export default function AdminItems() {
]); ]);
setItems(i.items); setItems(i.items);
setRarities(r.rarities); setRarities(r.rarities);
setCategories(c.categories || []); setCategories(
(c.names || c.categories || []).map((entry) =>
typeof entry === 'string' ? entry : entry.name
)
);
setItemForm((f) => ({ setItemForm((f) => ({
...f, ...f,
rarity: f.rarity || r.rarities[0] || 'MilSpec', rarity: f.rarity || r.rarities[0] || 'MilSpec',
+2 -1
View File
@@ -6,6 +6,7 @@ const NAV = [
{ to: '/admin/players', label: 'Players' }, { to: '/admin/players', label: 'Players' },
{ to: '/admin/cases', label: 'Cases' }, { to: '/admin/cases', label: 'Cases' },
{ to: '/admin/items', label: 'Items' }, { to: '/admin/items', label: 'Items' },
{ to: '/admin/categories', label: 'Categories' },
{ to: '/admin/drops', label: 'Drops' }, { to: '/admin/drops', label: 'Drops' },
]; ];
@@ -17,7 +18,7 @@ export default function AdminLayout() {
<div className="admin-shell"> <div className="admin-shell">
<aside className="admin-sidebar"> <aside className="admin-sidebar">
<div className="admin-sidebar-brand"> <div className="admin-sidebar-brand">
<span className="brand">CaseForge</span> <span className="brand">CaseOrion</span>
<span className="admin-sidebar-tag">Admin</span> <span className="admin-sidebar-tag">Admin</span>
</div> </div>
<nav className="admin-side-nav"> <nav className="admin-side-nav">
+6
View File
@@ -139,6 +139,12 @@ export const api = {
adminItems: () => request('/api/admin/items'), adminItems: () => request('/api/admin/items'),
adminRarities: () => request('/api/admin/rarities'), adminRarities: () => request('/api/admin/rarities'),
adminCategories: () => request('/api/admin/categories'), adminCategories: () => request('/api/admin/categories'),
createCategory: (body) =>
request('/api/admin/categories', { method: 'POST', body: JSON.stringify(body) }),
updateCategory: (id, body) =>
request(`/api/admin/categories/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deleteCategory: (id) =>
request(`/api/admin/categories/${id}`, { method: 'DELETE' }),
createCase: (body) => createCase: (body) =>
request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }), request('/api/admin/cases', { method: 'POST', body: JSON.stringify(body) }),
updateCase: (id, body) => updateCase: (id, body) =>
+2 -2
View File
@@ -7,7 +7,7 @@ import SessionLimitOverlay from './SessionLimitOverlay';
import { onInventoryChanged } from '../inventoryEvents'; import { onInventoryChanged } from '../inventoryEvents';
import { getSocket, onSessionLimitChange } from '../socket'; import { getSocket, onSessionLimitChange } from '../socket';
const FEED_KEY = 'caseforge-feed-drop'; const FEED_KEY = 'caseorion-feed-drop';
function ScrollToTop() { function ScrollToTop() {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -168,7 +168,7 @@ export default function Layout() {
<header className="topnav"> <header className="topnav">
<div className="topnav-left"> <div className="topnav-left">
<NavLink to="/dashboard" className="brand"> <NavLink to="/dashboard" className="brand">
CaseForge CaseOrion
</NavLink> </NavLink>
{isPlayer && ( {isPlayer && (
<button <button
+36
View File
@@ -1649,6 +1649,42 @@ a.balance-pill-link.active {
padding: 0.1rem 0.4rem; padding: 0.1rem 0.4rem;
} }
.admin-cat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.admin-cat-row {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.65rem 1rem;
padding: 0.7rem 0.85rem;
border: 1px solid var(--stroke);
background: var(--bg-deep);
}
.admin-cat-row .admin-row-main {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.admin-cat-edit {
width: 100%;
align-items: center;
}
.admin-cat-edit input {
flex: 1;
min-width: 10rem;
}
.admin-drop-pct { .admin-drop-pct {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 0.85rem; font-size: 0.85rem;
+4 -2
View File
@@ -5,6 +5,7 @@ import AdminLayout from '../admin/AdminLayout';
import AdminDashboard from '../admin/AdminDashboard'; import AdminDashboard from '../admin/AdminDashboard';
import AdminCases from '../admin/AdminCases'; import AdminCases from '../admin/AdminCases';
import AdminItems from '../admin/AdminItems'; import AdminItems from '../admin/AdminItems';
import AdminCategories from '../admin/AdminCategories';
import AdminDrops from '../admin/AdminDrops'; import AdminDrops from '../admin/AdminDrops';
import AdminPlayers from '../admin/AdminPlayers'; import AdminPlayers from '../admin/AdminPlayers';
@@ -31,9 +32,9 @@ function AdminLogin() {
return ( return (
<div className="auth-page"> <div className="auth-page">
<div className="auth-panel"> <div className="auth-panel">
<div className="brand">CaseForge</div> <div className="brand">CaseOrion</div>
<h1>Admin</h1> <h1>Admin</h1>
<p className="muted">Cases, items, drops, players & overview.</p> <p className="muted">Cases, items, categories, drops, players & overview.</p>
<form className="form" onSubmit={onSubmit}> <form className="form" onSubmit={onSubmit}>
<label> <label>
Username Username
@@ -71,6 +72,7 @@ export default function Admin() {
<Route path="players" element={<AdminPlayers />} /> <Route path="players" element={<AdminPlayers />} />
<Route path="cases" element={<AdminCases />} /> <Route path="cases" element={<AdminCases />} />
<Route path="items" element={<AdminItems />} /> <Route path="items" element={<AdminItems />} />
<Route path="categories" element={<AdminCategories />} />
<Route path="drops" element={<AdminDrops />} /> <Route path="drops" element={<AdminDrops />} />
<Route path="*" element={<Navigate to="/admin" replace />} /> <Route path="*" element={<Navigate to="/admin" replace />} />
</Route> </Route>
+1 -1
View File
@@ -31,7 +31,7 @@ export default function Login() {
return ( return (
<div className="auth-page"> <div className="auth-page">
<div className="auth-panel"> <div className="auth-panel">
<div className="brand">CaseForge</div> <div className="brand">CaseOrion</div>
<h1>Login</h1> <h1>Login</h1>
<p className="muted">Open crates. Build your inventory.</p> <p className="muted">Open crates. Build your inventory.</p>
<form className="form" onSubmit={onSubmit}> <form className="form" onSubmit={onSubmit}>
+1 -1
View File
@@ -257,7 +257,7 @@ export default function ProfilePage() {
<section className="section admin-block"> <section className="section admin-block">
<h2>Session</h2> <h2>Session</h2>
<p className="muted" style={{ marginBottom: '0.75rem' }}> <p className="muted" style={{ marginBottom: '0.75rem' }}>
Sign out of CaseForge on this device. Sign out of CaseOrion on this device.
</p> </p>
<button type="button" className="btn danger" onClick={onLogout} disabled={busy}> <button type="button" className="btn danger" onClick={onLogout} disabled={busy}>
Logout Logout
+1 -1
View File
@@ -36,7 +36,7 @@ export default function Register() {
return ( return (
<div className="auth-page"> <div className="auth-page">
<div className="auth-panel"> <div className="auth-panel">
<div className="brand">CaseForge</div> <div className="brand">CaseOrion</div>
<h1>Register</h1> <h1>Register</h1>
<p className="muted">Create an account and get starting credits.</p> <p className="muted">Create an account and get starting credits.</p>
<form className="form" onSubmit={onSubmit}> <form className="form" onSubmit={onSubmit}>
+1 -1
View File
@@ -1,4 +1,4 @@
const PREFIX = 'caseforge-pref'; const PREFIX = 'caseorion-pref';
function storageKey(userId, suffix) { function storageKey(userId, suffix) {
return `${PREFIX}-${userId}-${suffix}`; return `${PREFIX}-${userId}-${suffix}`;
+6
View File
@@ -61,6 +61,12 @@ model Case {
catalogEntries CatalogEntry[] catalogEntries CatalogEntry[]
} }
model Category {
id Int @id @default(autoincrement())
name String @unique
createdAt DateTime @default(now())
}
model Item { model Item {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
+2 -2
View File
@@ -7,7 +7,7 @@ export const ACHIEVEMENT_DEFS = [
{ {
key: 'welcome', key: 'welcome',
name: 'Welcome Aboard', name: 'Welcome Aboard',
description: 'Create your CaseForge account.', description: 'Create your CaseOrion account.',
icon: '★', icon: '★',
sortOrder: 1, sortOrder: 1,
}, },
@@ -70,7 +70,7 @@ export const ACHIEVEMENT_DEFS = [
{ {
key: 'playtime_hour', key: 'playtime_hour',
name: 'Hourglass', name: 'Hourglass',
description: 'Spend 1 hour playing on CaseForge.', description: 'Spend 1 hour playing on CaseOrion.',
icon: '⌛', icon: '⌛',
sortOrder: 10, sortOrder: 10,
}, },
+54 -1
View File
@@ -1,4 +1,6 @@
/** Suggested item categories (admin can still type custom ones). */ import { prisma } from './db.js';
/** Default categories seeded on boot / sync. */
export const ITEM_CATEGORIES = [ export const ITEM_CATEGORIES = [
'Rifle', 'Rifle',
'Pistol', 'Pistol',
@@ -16,3 +18,54 @@ export function normalizeCategory(value) {
const cat = String(value ?? '').trim(); const cat = String(value ?? '').trim();
return cat || 'Misc'; return cat || 'Misc';
} }
/** Ensure a single category row exists; returns normalized name. */
export async function ensureCategory(name) {
const normalized = normalizeCategory(name);
await prisma.category.upsert({
where: { name: normalized },
create: { name: normalized },
update: {},
});
return normalized;
}
/**
* Seed defaults + any category strings already used on items.
*/
export async function ensureCategories() {
const fromItems = await prisma.item.findMany({
select: { category: true },
distinct: ['category'],
});
const names = [
...new Set([
...ITEM_CATEGORIES,
...fromItems.map((r) => normalizeCategory(r.category)),
]),
];
for (const name of names) {
await prisma.category.upsert({
where: { name },
create: { name },
update: {},
});
}
}
export async function listCategoriesWithCounts() {
const [categories, grouped] = await Promise.all([
prisma.category.findMany({ orderBy: { name: 'asc' } }),
prisma.item.groupBy({
by: ['category'],
_count: { _all: true },
}),
]);
const countMap = new Map(grouped.map((g) => [g.category, g._count._all]));
return categories.map((c) => ({
id: c.id,
name: c.name,
itemCount: countMap.get(c.name) || 0,
createdAt: c.createdAt,
}));
}
+2
View File
@@ -27,6 +27,7 @@ import { recoverDuelSpins, getDuelRoom, listDuelRooms, startEmptyDuelRoomPurge }
import { getJackpotState } from './services/jackpot.js'; import { getJackpotState } from './services/jackpot.js';
import { ensureAdminFromEnv } from './ensureAdmin.js'; import { ensureAdminFromEnv } from './ensureAdmin.js';
import { ensureAchievements, checkAchievements } from './achievements.js'; import { ensureAchievements, checkAchievements } from './achievements.js';
import { ensureCategories } from './categories.js';
import { trackConnect, trackDisconnect } from './presence.js'; import { trackConnect, trackDisconnect } from './presence.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -239,6 +240,7 @@ async function bootstrap() {
try { try {
await ensureAdminFromEnv(); await ensureAdminFromEnv();
await ensureAchievements(); await ensureAchievements();
await ensureCategories();
} catch (err) { } catch (err) {
console.error(err.message || err); console.error(err.message || err);
process.exit(1); process.exit(1);
+88 -12
View File
@@ -6,7 +6,12 @@ import { isValidRarity, RARITIES } from '../rarities.js';
import { createUploader } from '../upload.js'; import { createUploader } from '../upload.js';
import { onlineUserCount, totalSocketCount } from '../presence.js'; import { onlineUserCount, totalSocketCount } from '../presence.js';
import { ACHIEVEMENT_DEFS } from '../achievements.js'; import { ACHIEVEMENT_DEFS } from '../achievements.js';
import { ITEM_CATEGORIES, normalizeCategory } from '../categories.js'; import {
ITEM_CATEGORIES,
normalizeCategory,
ensureCategory,
listCategoriesWithCounts,
} from '../categories.js';
const router = Router(); const router = Router();
const upload = createUploader('asset'); const upload = createUploader('asset');
@@ -42,22 +47,93 @@ router.get('/rarities', requireAdmin, (_req, res) => {
router.get('/categories', requireAdmin, async (_req, res) => { router.get('/categories', requireAdmin, async (_req, res) => {
try { try {
const rows = await prisma.item.findMany({ const categories = await listCategoriesWithCounts();
select: { category: true }, res.json({
distinct: ['category'], categories,
orderBy: { category: 'asc' }, names: categories.map((c) => c.name),
suggested: ITEM_CATEGORIES,
}); });
const fromDb = rows.map((r) => r.category).filter(Boolean);
const categories = [...new Set([...ITEM_CATEGORIES, ...fromDb])].sort((a, b) =>
a.localeCompare(b)
);
res.json({ categories, suggested: ITEM_CATEGORIES });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Failed to load categories' }); res.status(500).json({ error: 'Failed to load categories' });
} }
}); });
router.post('/categories', requireAdmin, async (req, res) => {
try {
const name = normalizeCategory(req.body.name);
const existing = await prisma.category.findUnique({ where: { name } });
if (existing) {
return res.status(400).json({ error: 'Category already exists' });
}
const category = await prisma.category.create({ data: { name } });
res.status(201).json({
category: { ...category, itemCount: 0 },
});
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create category' });
}
});
router.put('/categories/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const name = normalizeCategory(req.body.name);
const current = await prisma.category.findUnique({ where: { id } });
if (!current) {
return res.status(404).json({ error: 'Category not found' });
}
if (name !== current.name) {
const clash = await prisma.category.findUnique({ where: { name } });
if (clash) {
return res.status(400).json({ error: 'Category name already taken' });
}
}
const updated = await prisma.$transaction(async (tx) => {
const category = await tx.category.update({
where: { id },
data: { name },
});
if (name !== current.name) {
await tx.item.updateMany({
where: { category: current.name },
data: { category: name },
});
}
const itemCount = await tx.item.count({ where: { category: name } });
return { ...category, itemCount };
});
res.json({ category: updated });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update category' });
}
});
router.delete('/categories/:id', requireAdmin, async (req, res) => {
try {
const id = Number(req.params.id);
const current = await prisma.category.findUnique({ where: { id } });
if (!current) {
return res.status(404).json({ error: 'Category not found' });
}
const itemCount = await prisma.item.count({ where: { category: current.name } });
if (itemCount > 0) {
return res.status(400).json({
error: `Cannot delete: ${itemCount} item${itemCount === 1 ? '' : 's'} still use this category`,
});
}
await prisma.category.delete({ where: { id } });
res.json({ ok: true });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to delete category' });
}
});
router.get('/stats', requireAdmin, async (_req, res) => { router.get('/stats', requireAdmin, async (_req, res) => {
try { try {
const [ const [
@@ -348,7 +424,7 @@ router.post('/items', requireAdmin, async (req, res) => {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
const imageUrl = String(req.body.imageUrl || ''); const imageUrl = String(req.body.imageUrl || '');
const rarity = String(req.body.rarity || ''); const rarity = String(req.body.rarity || '');
const category = normalizeCategory(req.body.category); const category = await ensureCategory(req.body.category);
const marketValue = Number(req.body.marketValue); const marketValue = Number(req.body.marketValue);
if (!name) return res.status(400).json({ error: 'Name is required' }); if (!name) return res.status(400).json({ error: 'Name is required' });
@@ -382,7 +458,7 @@ router.put('/items/:id', requireAdmin, async (req, res) => {
data.rarity = req.body.rarity; data.rarity = req.body.rarity;
} }
if (req.body.category !== undefined) { if (req.body.category !== undefined) {
data.category = normalizeCategory(req.body.category); data.category = await ensureCategory(req.body.category);
} }
if (req.body.marketValue !== undefined) { if (req.body.marketValue !== undefined) {
const marketValue = Number(req.body.marketValue); const marketValue = Number(req.body.marketValue);