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:
@@ -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",
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user