feat: add ability to edit items.
All checks were successful
Deploy to Production / test (push) Successful in 37s
All checks were successful
Deploy to Production / test (push) Successful in 37s
This commit is contained in:
@@ -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 =====
|
||||
|
||||
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(
|
||||
"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",
|
||||
@@ -1032,7 +1124,13 @@ function ItemPreviewCard({
|
||||
|
||||
// ===== Main Export =====
|
||||
|
||||
export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
export function ItemStudio({
|
||||
onSuccess,
|
||||
editItemId,
|
||||
}: {
|
||||
onSuccess: () => void;
|
||||
editItemId?: number;
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(defaultDraft());
|
||||
const [imageMode, setImageMode] = useState<"upload" | "url">("upload");
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
@@ -1046,6 +1144,13 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [validationErrors, setValidationErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [loadingItem, setLoadingItem] = useState(false);
|
||||
|
||||
const initialEditStateRef = useRef<{
|
||||
draft: Draft;
|
||||
iconUrlInput: string;
|
||||
imageUrlInput: string;
|
||||
} | null>(null);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -1053,6 +1158,57 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
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(
|
||||
(file: File) => {
|
||||
if (imagePreview) URL.revokeObjectURL(imagePreview);
|
||||
@@ -1093,7 +1249,7 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
const validate = (): boolean => {
|
||||
const errs: Record<string, string> = {};
|
||||
if (!draft.name.trim()) errs.name = "Name is required";
|
||||
if (imageMode === "upload" && !imageFile)
|
||||
if (imageMode === "upload" && !imageFile && !editItemId)
|
||||
errs.image = "Please upload an image";
|
||||
if (imageMode === "url" && !iconUrlInput.trim())
|
||||
errs.iconUrl = "Icon URL is required";
|
||||
@@ -1167,69 +1323,137 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
const basePayload: Record<string, unknown> = {
|
||||
name: draft.name.trim(),
|
||||
description: draft.description.trim() || null,
|
||||
type: draft.type,
|
||||
rarity: draft.rarity,
|
||||
price:
|
||||
draft.price && Number(draft.price) > 0 ? Number(draft.price) : null,
|
||||
iconUrl: imageMode === "url" ? iconUrlInput.trim() : "",
|
||||
imageUrl: imageMode === "url" ? imageUrlInput.trim() : "",
|
||||
usageData:
|
||||
draft.effects.length > 0
|
||||
? { consume: draft.consume, effects }
|
||||
: null,
|
||||
};
|
||||
|
||||
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",
|
||||
if (editItemId) {
|
||||
// ── Edit mode: PUT existing item ──
|
||||
const editPayload = { ...basePayload };
|
||||
if (imageMode === "url") {
|
||||
editPayload.iconUrl = iconUrlInput.trim();
|
||||
editPayload.imageUrl = imageUrlInput.trim();
|
||||
}
|
||||
|
||||
const putRes = await fetch(`/api/items/${editItemId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
body: JSON.stringify(editPayload),
|
||||
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");
|
||||
}
|
||||
if (!putRes.ok) {
|
||||
const body = (await putRes
|
||||
.json()
|
||||
.catch(() => ({ error: putRes.statusText }))) as { error?: string };
|
||||
throw new Error(body.error || "Failed to save item");
|
||||
}
|
||||
|
||||
setSuccess(true);
|
||||
setTimeout(() => {
|
||||
setDraft(defaultDraft());
|
||||
clearImage();
|
||||
setIconUrlInput("");
|
||||
setImageUrlInput("");
|
||||
setValidationErrors({});
|
||||
setSuccess(false);
|
||||
onSuccess();
|
||||
}, 1200);
|
||||
// Upload new image if one was selected
|
||||
if (imageMode === "upload" && imageFile) {
|
||||
const form = new FormData();
|
||||
form.append("image", imageFile);
|
||||
const iconRes = await fetch(`/api/items/${editItemId}/icon`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
credentials: "same-origin",
|
||||
});
|
||||
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) {
|
||||
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 {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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();
|
||||
setIconUrlInput("");
|
||||
setImageUrlInput("");
|
||||
setValidationErrors({});
|
||||
setError(null);
|
||||
};
|
||||
@@ -1237,10 +1461,24 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
const previewImageSrc =
|
||||
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 (
|
||||
<div className="grid grid-cols-[1fr_300px] gap-6 items-start pb-8">
|
||||
{/* ── Left: Form ── */}
|
||||
<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 */}
|
||||
<SectionCard title="Identity">
|
||||
<Field label="Item Name *" error={validationErrors.name}>
|
||||
@@ -1562,12 +1800,12 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) {
|
||||
{success ? (
|
||||
<>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
Created!
|
||||
{editItemId ? "Saved!" : "Created!"}
|
||||
</>
|
||||
) : submitting ? (
|
||||
"Creating..."
|
||||
editItemId ? "Saving..." : "Creating..."
|
||||
) : (
|
||||
"Create Item"
|
||||
editItemId ? "Save Changes" : "Create Item"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -127,9 +127,11 @@ function SearchFilterBar({
|
||||
function ItemTable({
|
||||
items,
|
||||
loading,
|
||||
onItemClick,
|
||||
}: {
|
||||
items: Item[];
|
||||
loading: boolean;
|
||||
onItemClick: (item: Item) => void;
|
||||
}) {
|
||||
const columns = ["ID", "Icon", "Name", "Type", "Rarity", "Price", "Description"];
|
||||
|
||||
@@ -195,7 +197,9 @@ function ItemTable({
|
||||
{items.map((item) => (
|
||||
<tr
|
||||
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">
|
||||
<span className="text-sm font-mono text-text-tertiary">
|
||||
@@ -397,6 +401,7 @@ export default function Items() {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<Tab>("all");
|
||||
const [searchInput, setSearchInput] = useState(filters.search);
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchInput(filters.search);
|
||||
@@ -435,7 +440,7 @@ export default function Items() {
|
||||
All Items
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("studio")}
|
||||
onClick={() => { setEditingItemId(null); setActiveTab("studio"); }}
|
||||
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",
|
||||
activeTab === "studio"
|
||||
@@ -521,7 +526,14 @@ export default function Items() {
|
||||
onRarityChange={(v) => setFilters({ rarity: v })}
|
||||
onClear={handleClearFilters}
|
||||
/>
|
||||
<ItemTable items={items} loading={loading} />
|
||||
<ItemTable
|
||||
items={items}
|
||||
loading={loading}
|
||||
onItemClick={(item) => {
|
||||
setEditingItemId(item.id);
|
||||
setActiveTab("studio");
|
||||
}}
|
||||
/>
|
||||
{!loading && items.length > 0 && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
@@ -535,7 +547,9 @@ export default function Items() {
|
||||
</div>
|
||||
) : activeTab === "studio" ? (
|
||||
<ItemStudio
|
||||
editItemId={editingItemId ?? undefined}
|
||||
onSuccess={() => {
|
||||
setEditingItemId(null);
|
||||
refetch();
|
||||
setActiveTab("all");
|
||||
}}
|
||||
|
||||
@@ -641,23 +641,23 @@ function InventoryAddForm({
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<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">
|
||||
<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
|
||||
type="number"
|
||||
value={quantity}
|
||||
@@ -674,10 +674,10 @@ function InventoryAddForm({
|
||||
onClick={handleAdd}
|
||||
disabled={!selectedItemId}
|
||||
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",
|
||||
"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" />
|
||||
|
||||
@@ -15,6 +15,7 @@ export default defineConfig({
|
||||
proxy: {
|
||||
"/api": "http://localhost:3000",
|
||||
"/auth": "http://localhost:3000",
|
||||
"/assets": "http://localhost:3000",
|
||||
"/ws": {
|
||||
target: "ws://localhost:3000",
|
||||
ws: true,
|
||||
|
||||
Reference in New Issue
Block a user