diff --git a/api/src/routes/assets.routes.ts b/api/src/routes/assets.routes.ts index 3c351fb..7b0fca4 100644 --- a/api/src/routes/assets.routes.ts +++ b/api/src/routes/assets.routes.ts @@ -66,7 +66,7 @@ async function handler(ctx: RouteContext): Promise { return new Response(file, { headers: { "Content-Type": contentType, - "Cache-Control": "public, max-age=86400", // Cache for 24 hours + "Cache-Control": "no-cache", } }); } diff --git a/api/src/routes/items.routes.ts b/api/src/routes/items.routes.ts index 7ee42ad..d474739 100644 --- a/api/src/routes/items.routes.ts +++ b/api/src/routes/items.routes.ts @@ -188,7 +188,7 @@ async function handler(ctx: RouteContext): Promise { const filePath = join(assetsDir, fileName); await Bun.write(filePath, buffer); - const assetUrl = `/assets/items/${fileName}`; + const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`; await itemsService.updateItem(item.id, { iconUrl: assetUrl, imageUrl: assetUrl, @@ -352,7 +352,7 @@ async function handler(ctx: RouteContext): Promise { const filePath = join(assetsDir, fileName); await Bun.write(filePath, buffer); - const assetUrl = `/assets/items/${fileName}`; + const assetUrl = `/assets/items/${fileName}?v=${Date.now()}`; const updatedItem = await itemsService.updateItem(id, { iconUrl: assetUrl, imageUrl: assetUrl, diff --git a/panel/src/pages/ItemStudio.tsx b/panel/src/pages/ItemStudio.tsx index 4b58316..35309d7 100644 --- a/panel/src/pages/ItemStudio.tsx +++ b/panel/src/pages/ItemStudio.tsx @@ -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(defaultDraft()); const [imageMode, setImageMode] = useState<"upload" | "url">("upload"); const [imageFile, setImageFile] = useState(null); @@ -1046,6 +1144,13 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) { const [validationErrors, setValidationErrors] = useState< Record >({}); + const [loadingItem, setLoadingItem] = useState(false); + + const initialEditStateRef = useRef<{ + draft: Draft; + iconUrlInput: string; + imageUrlInput: string; + } | null>(null); const fileInputRef = useRef(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(`/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 = {}; 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 = { + const basePayload: Record = { 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 ( +
+ + Loading item… +
+ ); + } + return (
{/* ── Left: Form ── */}
+ {editItemId && ( +
+ Editing item #{editItemId} +
+ )} {/* Identity */} @@ -1562,12 +1800,12 @@ export function ItemStudio({ onSuccess }: { onSuccess: () => void }) { {success ? ( <> - Created! + {editItemId ? "Saved!" : "Created!"} ) : submitting ? ( - "Creating..." + editItemId ? "Saving..." : "Creating..." ) : ( - "Create Item" + editItemId ? "Save Changes" : "Create Item" )}
diff --git a/panel/src/pages/Items.tsx b/panel/src/pages/Items.tsx index 13c24ee..9b5f998 100644 --- a/panel/src/pages/Items.tsx +++ b/panel/src/pages/Items.tsx @@ -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) => ( onItemClick(item)} + title="Click to edit" > @@ -397,6 +401,7 @@ export default function Items() { const [activeTab, setActiveTab] = useState("all"); const [searchInput, setSearchInput] = useState(filters.search); + const [editingItemId, setEditingItemId] = useState(null); useEffect(() => { setSearchInput(filters.search); @@ -435,7 +440,7 @@ export default function Items() { All Items