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