feat: add ability to edit items.
All checks were successful
Deploy to Production / test (push) Successful in 37s

This commit is contained in:
syntaxbullet
2026-02-19 15:53:13 +01:00
parent 7cc2f61db6
commit 9eba64621a
6 changed files with 320 additions and 67 deletions

View File

@@ -66,7 +66,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
return new Response(file, { return new Response(file, {
headers: { headers: {
"Content-Type": contentType, "Content-Type": contentType,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours "Cache-Control": "no-cache",
} }
}); });
} }

View File

@@ -188,7 +188,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const filePath = join(assetsDir, fileName); const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer); await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`; const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
await itemsService.updateItem(item.id, { await itemsService.updateItem(item.id, {
iconUrl: assetUrl, iconUrl: assetUrl,
imageUrl: assetUrl, imageUrl: assetUrl,
@@ -352,7 +352,7 @@ async function handler(ctx: RouteContext): Promise<Response | null> {
const filePath = join(assetsDir, fileName); const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer); await Bun.write(filePath, buffer);
const assetUrl = `/assets/items/${fileName}`; const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`;
const updatedItem = await itemsService.updateItem(id, { const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl, iconUrl: assetUrl,
imageUrl: assetUrl, imageUrl: assetUrl,

View File

@@ -169,6 +169,39 @@ const LOOT_TYPE_META: Record<
}, },
}; };
// ===== Full item shape returned by GET /api/items/:id =====
interface LootPayloadEntry {
type: LootEntryType;
weight: number;
amount?: number;
minAmount?: number;
maxAmount?: number;
itemId?: number;
message?: string;
}
type StoredEffect =
| { type: "ADD_XP"; amount: number }
| { type: "ADD_BALANCE"; amount: number }
| { type: "REPLY_MESSAGE"; message: string }
| { type: "XP_BOOST"; multiplier: number; durationSeconds?: number }
| { type: "TEMP_ROLE"; roleId: string; durationSeconds?: number }
| { type: "COLOR_ROLE"; roleId: string }
| { type: "LOOTBOX"; pool: LootPayloadEntry[] };
interface FullItem {
id: number;
name: string;
description: string | null;
type: ItemType;
rarity: ItemRarity;
price: string | null;
iconUrl: string;
imageUrl: string;
usageData: { consume: boolean; effects: StoredEffect[] } | null;
}
// ===== Helpers ===== // ===== Helpers =====
function uid() { function uid() {
@@ -216,6 +249,65 @@ function defaultDraft(): Draft {
}; };
} }
function draftFromItem(item: FullItem): Draft {
const effects: EffectDraft[] = (item.usageData?.effects ?? []).map((eff) => {
const base = makeEffect(eff.type as EffectKind);
switch (eff.type) {
case "ADD_XP":
case "ADD_BALANCE":
return { ...base, amount: String(eff.amount) };
case "REPLY_MESSAGE":
return { ...base, message: eff.message };
case "XP_BOOST":
return {
...base,
multiplier: String(eff.multiplier),
durationSeconds: String(eff.durationSeconds ?? ""),
};
case "TEMP_ROLE":
return {
...base,
roleId: eff.roleId,
durationSeconds: String(eff.durationSeconds ?? ""),
};
case "COLOR_ROLE":
return { ...base, roleId: eff.roleId };
case "LOOTBOX":
return {
...base,
pool: eff.pool.map((entry) => ({
_id: uid(),
type: entry.type,
weight: String(entry.weight),
amountMode:
entry.minAmount !== undefined || entry.maxAmount !== undefined
? ("range" as const)
: ("fixed" as const),
amount: String(entry.amount ?? ""),
minAmount: String(entry.minAmount ?? ""),
maxAmount: String(entry.maxAmount ?? ""),
itemId: String(entry.itemId ?? ""),
selectedItemName: "",
selectedItemRarity: "",
message: entry.message ?? "",
})),
};
default:
return base;
}
});
return {
name: item.name,
description: item.description ?? "",
type: item.type,
rarity: item.rarity,
price: item.price ? String(parseInt(item.price)) : "",
consume: item.usageData?.consume ?? false,
effects,
};
}
const inputCls = cn( const inputCls = cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground", "w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30", "focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
@@ -1032,7 +1124,13 @@ function ItemPreviewCard({
// ===== Main Export ===== // ===== Main Export =====
export function ItemStudio({ onSuccess }: { onSuccess: () => void }) { export function ItemStudio({
onSuccess,
editItemId,
}: {
onSuccess: () => void;
editItemId?: number;
}) {
const [draft, setDraft] = useState<Draft>(defaultDraft()); const [draft, setDraft] = useState<Draft>(defaultDraft());
const [imageMode, setImageMode] = useState<"upload" | "url">("upload"); const [imageMode, setImageMode] = useState<"upload" | "url">("upload");
const [imageFile, setImageFile] = useState<File | null>(null); const [imageFile, setImageFile] = useState<File | null>(null);
@@ -1046,6 +1144,13 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
const [validationErrors, setValidationErrors] = useState< const [validationErrors, setValidationErrors] = useState<
Record<string, string> Record<string, string>
>({}); >({});
const [loadingItem, setLoadingItem] = useState(false);
const initialEditStateRef = useRef<{
draft: Draft;
iconUrlInput: string;
imageUrlInput: string;
} | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@@ -1053,6 +1158,57 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
setDraft((d) => ({ ...d, ...fields })); setDraft((d) => ({ ...d, ...fields }));
}, []); }, []);
useEffect(() => {
if (!editItemId) {
initialEditStateRef.current = null;
setDraft(defaultDraft());
setImageMode("upload");
setIconUrlInput("");
setImageUrlInput("");
setImageFile(null);
setImagePreview(null);
setValidationErrors({});
setError(null);
return;
}
let cancelled = false;
setLoadingItem(true);
setError(null);
get<FullItem>(`/api/items/${editItemId}`)
.then((item) => {
if (cancelled) return;
const newDraft = draftFromItem(item);
const newIconUrl = item.iconUrl ?? "";
const newImageUrl = item.imageUrl ?? "";
setDraft(newDraft);
setImageMode("url");
setIconUrlInput(newIconUrl);
setImageUrlInput(newImageUrl);
setImageFile(null);
setImagePreview(null);
setValidationErrors({});
setError(null);
initialEditStateRef.current = {
draft: newDraft,
iconUrlInput: newIconUrl,
imageUrlInput: newImageUrl,
};
})
.catch((e) => {
if (cancelled) return;
setError(e instanceof Error ? e.message : "Failed to load item");
})
.finally(() => {
if (!cancelled) setLoadingItem(false);
});
return () => {
cancelled = true;
};
}, [editItemId]);
const handleImageFile = useCallback( const handleImageFile = useCallback(
(file: File) => { (file: File) => {
if (imagePreview) URL.revokeObjectURL(imagePreview); if (imagePreview) URL.revokeObjectURL(imagePreview);
@@ -1093,7 +1249,7 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
const validate = (): boolean => { const validate = (): boolean => {
const errs: Record<string, string> = {}; const errs: Record<string, string> = {};
if (!draft.name.trim()) errs.name = "Name is required"; if (!draft.name.trim()) errs.name = "Name is required";
if (imageMode === "upload" && !imageFile) if (imageMode === "upload" && !imageFile && !editItemId)
errs.image = "Please upload an image"; errs.image = "Please upload an image";
if (imageMode === "url" && !iconUrlInput.trim()) if (imageMode === "url" && !iconUrlInput.trim())
errs.iconUrl = "Icon URL is required"; errs.iconUrl = "Icon URL is required";
@@ -1167,69 +1323,137 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
}) })
.filter(Boolean); .filter(Boolean);
const payload: Record<string, unknown> = { const basePayload: Record<string, unknown> = {
name: draft.name.trim(), name: draft.name.trim(),
description: draft.description.trim() || null, description: draft.description.trim() || null,
type: draft.type, type: draft.type,
rarity: draft.rarity, rarity: draft.rarity,
price: price:
draft.price && Number(draft.price) > 0 ? Number(draft.price) : null, draft.price && Number(draft.price) > 0 ? Number(draft.price) : null,
iconUrl: imageMode === "url" ? iconUrlInput.trim() : "",
imageUrl: imageMode === "url" ? imageUrlInput.trim() : "",
usageData: usageData:
draft.effects.length > 0 draft.effects.length > 0
? { consume: draft.consume, effects } ? { consume: draft.consume, effects }
: null, : null,
}; };
let res: Response; if (editItemId) {
if (imageMode === "upload" && imageFile) { // ── Edit mode: PUT existing item ──
const form = new FormData(); const editPayload = { ...basePayload };
form.append("data", JSON.stringify(payload)); if (imageMode === "url") {
form.append("image", imageFile); editPayload.iconUrl = iconUrlInput.trim();
res = await fetch("/api/items", { editPayload.imageUrl = imageUrlInput.trim();
method: "POST", }
body: form,
credentials: "same-origin", const putRes = await fetch(`/api/items/${editItemId}`, {
}); method: "PUT",
} else {
res = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload), body: JSON.stringify(editPayload),
credentials: "same-origin", credentials: "same-origin",
}); });
}
if (!res.ok) { if (!putRes.ok) {
const body = (await res const body = (await putRes
.json() .json()
.catch(() => ({ error: res.statusText }))) as { error?: string }; .catch(() => ({ error: putRes.statusText }))) as { error?: string };
throw new Error(body.error || "Failed to create item"); throw new Error(body.error || "Failed to save item");
} }
setSuccess(true); // Upload new image if one was selected
setTimeout(() => { if (imageMode === "upload" && imageFile) {
setDraft(defaultDraft()); const form = new FormData();
clearImage(); form.append("image", imageFile);
setIconUrlInput(""); const iconRes = await fetch(`/api/items/${editItemId}/icon`, {
setImageUrlInput(""); method: "POST",
setValidationErrors({}); body: form,
setSuccess(false); credentials: "same-origin",
onSuccess(); });
}, 1200); if (!iconRes.ok) {
const body = (await iconRes
.json()
.catch(() => ({ error: iconRes.statusText }))) as {
error?: string;
};
throw new Error(body.error || "Failed to upload image");
}
}
setSuccess(true);
setTimeout(() => {
setSuccess(false);
onSuccess();
}, 1200);
} else {
// ── Create mode: POST new item ──
const payload = {
...basePayload,
iconUrl: imageMode === "url" ? iconUrlInput.trim() : "",
imageUrl: imageMode === "url" ? imageUrlInput.trim() : "",
};
let res: Response;
if (imageMode === "upload" && imageFile) {
const form = new FormData();
form.append("data", JSON.stringify(payload));
form.append("image", imageFile);
res = await fetch("/api/items", {
method: "POST",
body: form,
credentials: "same-origin",
});
} else {
res = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "same-origin",
});
}
if (!res.ok) {
const body = (await res
.json()
.catch(() => ({ error: res.statusText }))) as { error?: string };
throw new Error(body.error || "Failed to create item");
}
setSuccess(true);
setTimeout(() => {
setDraft(defaultDraft());
clearImage();
setIconUrlInput("");
setImageUrlInput("");
setValidationErrors({});
setSuccess(false);
onSuccess();
}, 1200);
}
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : "Failed to create item"); setError(
e instanceof Error
? e.message
: editItemId
? "Failed to save item"
: "Failed to create item"
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const handleReset = () => { const handleReset = () => {
setDraft(defaultDraft()); if (editItemId && initialEditStateRef.current) {
const s = initialEditStateRef.current;
setDraft(s.draft);
setImageMode("url");
setIconUrlInput(s.iconUrlInput);
setImageUrlInput(s.imageUrlInput);
} else {
setDraft(defaultDraft());
setImageMode("upload");
setIconUrlInput("");
setImageUrlInput("");
}
clearImage(); clearImage();
setIconUrlInput("");
setImageUrlInput("");
setValidationErrors({}); setValidationErrors({});
setError(null); setError(null);
}; };
@@ -1237,10 +1461,24 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
const previewImageSrc = const previewImageSrc =
imageMode === "upload" ? imagePreview : iconUrlInput || null; imageMode === "upload" ? imagePreview : iconUrlInput || null;
if (loadingItem) {
return (
<div className="flex items-center justify-center py-20 gap-3 text-text-tertiary">
<Loader2 className="w-5 h-5 animate-spin" />
<span className="text-sm">Loading item</span>
</div>
);
}
return ( return (
<div className="grid grid-cols-[1fr_300px] gap-6 items-start pb-8"> <div className="grid grid-cols-[1fr_300px] gap-6 items-start pb-8">
{/* ── Left: Form ── */} {/* ── Left: Form ── */}
<div className="space-y-4"> <div className="space-y-4">
{editItemId && (
<div className="flex items-center gap-2 text-xs text-text-tertiary">
<span>Editing item #{editItemId}</span>
</div>
)}
{/* Identity */} {/* Identity */}
<SectionCard title="Identity"> <SectionCard title="Identity">
<Field label="Item Name *" error={validationErrors.name}> <Field label="Item Name *" error={validationErrors.name}>
@@ -1562,12 +1800,12 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
{success ? ( {success ? (
<> <>
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
Created! {editItemId ? "Saved!" : "Created!"}
</> </>
) : submitting ? ( ) : submitting ? (
"Creating..." editItemId ? "Saving..." : "Creating..."
) : ( ) : (
"Create Item" editItemId ? "Save Changes" : "Create Item"
)} )}
</button> </button>
</div> </div>

View File

@@ -127,9 +127,11 @@ function SearchFilterBar({
function ItemTable({ function ItemTable({
items, items,
loading, loading,
onItemClick,
}: { }: {
items: Item[]; items: Item[];
loading: boolean; loading: boolean;
onItemClick: (item: Item) => void;
}) { }) {
const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"]; const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"];
@@ -195,7 +197,9 @@ function ItemTable({
{items.map((item) => ( {items.map((item) => (
<tr <tr
key={item.id} key={item.id}
className="border-b border-border hover:bg-raised transition-colors" className="border-b border-border hover:bg-raised transition-colors cursor-pointer"
onClick={() => onItemClick(item)}
title="Click to edit"
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className="text-sm font-mono text-text-tertiary"> <span className="text-sm font-mono text-text-tertiary">
@@ -397,6 +401,7 @@ export default function Items() {
const [activeTab, setActiveTab] = useState<Tab>("all"); const [activeTab, setActiveTab] = useState<Tab>("all");
const [searchInput, setSearchInput] = useState(filters.search); const [searchInput, setSearchInput] = useState(filters.search);
const [editingItemId, setEditingItemId] = useState<number | null>(null);
useEffect(() => { useEffect(() => {
setSearchInput(filters.search); setSearchInput(filters.search);
@@ -435,7 +440,7 @@ export default function Items() {
All Items All Items
</button> </button>
<button <button
onClick={() => setActiveTab("studio")} onClick={() => { setEditingItemId(null); setActiveTab("studio"); }}
className={cn( className={cn(
"inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px", "inline-flex items-center gap-2 px-4 py-2.5 text-sm font-medium whitespace-nowrap transition-colors border-b-2 -mb-px",
activeTab === "studio" activeTab === "studio"
@@ -521,7 +526,14 @@ export default function Items() {
onRarityChange={(v) => setFilters({ rarity: v })} onRarityChange={(v) => setFilters({ rarity: v })}
onClear={handleClearFilters} onClear={handleClearFilters}
/> />
<ItemTable items={items} loading={loading} /> <ItemTable
items={items}
loading={loading}
onItemClick={(item) => {
setEditingItemId(item.id);
setActiveTab("studio");
}}
/>
{!loading && items.length > 0 && ( {!loading && items.length > 0 && (
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
@@ -535,7 +547,9 @@ export default function Items() {
</div> </div>
) : activeTab === "studio" ? ( ) : activeTab === "studio" ? (
<ItemStudio <ItemStudio
editItemId={editingItemId ?? undefined}
onSuccess={() => { onSuccess={() => {
setEditingItemId(null);
refetch(); refetch();
setActiveTab("all"); setActiveTab("all");
}} }}

View File

@@ -641,23 +641,23 @@ function InventoryAddForm({
return ( return (
<div className="space-y-3"> <div className="space-y-3">
<p className="text-xs font-semibold text-text-secondary">Add Item</p> <p className="text-xs font-semibold text-text-secondary">Add Item</p>
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
className={cn(
"w-full bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">Select item...</option>
{Array.isArray(items) && items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex gap-2"> <div className="flex gap-2">
<select
value={selectedItemId}
onChange={(e) => setSelectedItemId(e.target.value)}
className={cn(
"flex-1 bg-input border border-border rounded-md px-3 py-2 text-sm text-foreground",
"focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary/30",
"transition-colors"
)}
>
<option value="">Select item...</option>
{Array.isArray(items) && items.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<input <input
type="number" type="number"
value={quantity} value={quantity}
@@ -674,10 +674,10 @@ function InventoryAddForm({
onClick={handleAdd} onClick={handleAdd}
disabled={!selectedItemId} disabled={!selectedItemId}
className={cn( className={cn(
"px-3 py-2 rounded-md text-sm font-medium transition-colors", "flex-1 px-3 py-2 rounded-md text-sm font-medium transition-colors",
"bg-primary text-white hover:bg-primary/90", "bg-primary text-white hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed", "disabled:opacity-50 disabled:cursor-not-allowed",
"flex items-center gap-1.5" "flex items-center justify-center gap-1.5"
)} )}
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />

View File

@@ -15,6 +15,7 @@ export default defineConfig({
proxy: { proxy: {
"/api": "http://localhost:3000", "/api": "http://localhost:3000",
"/auth": "http://localhost:3000", "/auth": "http://localhost:3000",
"/assets": "http://localhost:3000",
"/ws": { "/ws": {
target: "ws://localhost:3000", target: "ws://localhost:3000",
ws: true, ws: true,