admin
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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}`;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user