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
+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>
);
}