prstige
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user