380 lines
12 KiB
React
380 lines
12 KiB
React
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { api, formatCredits, compareCents, centsJson, toCentsBigInt } from '../api';
|
|
import ImageField from './ImageField';
|
|
|
|
const emptyItem = {
|
|
name: '',
|
|
rarity: 'MilSpec',
|
|
category: 'Misc',
|
|
marketValue: 100,
|
|
imageUrl: '',
|
|
};
|
|
|
|
function CategoryField({ value, onChange, categories }) {
|
|
const options =
|
|
value && !categories.includes(value) ? [...categories, value] : categories;
|
|
return (
|
|
<select value={value} onChange={(e) => onChange(e.target.value)} required>
|
|
{options.length === 0 ? (
|
|
<option value="">No categories — create one first</option>
|
|
) : (
|
|
options.map((c) => (
|
|
<option key={c} value={c}>
|
|
{c}
|
|
</option>
|
|
))
|
|
)}
|
|
</select>
|
|
);
|
|
}
|
|
|
|
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);
|
|
const names = (c.names || c.categories || []).map((entry) =>
|
|
typeof entry === 'string' ? entry : entry.name
|
|
);
|
|
setCategories(names);
|
|
setItemForm((f) => {
|
|
const nextCategory =
|
|
f.category && names.includes(f.category)
|
|
? f.category
|
|
: names.includes('Misc')
|
|
? 'Misc'
|
|
: names[0] || '';
|
|
return {
|
|
...f,
|
|
rarity: f.rarity || r.rarities[0] || 'MilSpec',
|
|
category: nextCategory,
|
|
};
|
|
});
|
|
}, []);
|
|
|
|
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 compareCents(b.marketValue, 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: centsJson(toCentsBigInt(itemForm.marketValue)),
|
|
});
|
|
setItemForm({
|
|
...emptyItem,
|
|
rarity: rarities[0] || 'MilSpec',
|
|
category: categories.includes('Misc')
|
|
? 'Misc'
|
|
: categories[0] || '',
|
|
});
|
|
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
|
|
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: centsJson(toCentsBigInt(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
|
|
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>
|
|
);
|
|
}
|