feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
All checks were successful
Deploy to Production / test (push) Successful in 44s

This commit is contained in:
syntaxbullet
2026-02-06 12:19:14 +01:00
parent 109b36ffe2
commit 34958aa220
22 changed files with 3718 additions and 15 deletions

306
web/src/hooks/use-items.ts Normal file
View File

@@ -0,0 +1,306 @@
/**
* useItems Hook
* Manages item data fetching and mutations for the items management interface.
*/
import { useState, useCallback, useEffect } from "react";
import { toast } from "sonner";
// Full item type matching the database schema
export interface ItemWithUsage {
id: number;
name: string;
description: string | null;
rarity: string | null;
type: string | null;
price: bigint | null;
iconUrl: string | null;
imageUrl: string | null;
usageData: {
consume: boolean;
effects: Array<{
type: string;
[key: string]: any;
}>;
} | null;
}
export interface ItemFilters {
search?: string;
type?: string;
rarity?: string;
limit?: number;
offset?: number;
}
export interface ItemsResponse {
items: ItemWithUsage[];
total: number;
}
export interface CreateItemData {
name: string;
description?: string | null;
rarity?: 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary';
type: 'MATERIAL' | 'CONSUMABLE' | 'EQUIPMENT' | 'QUEST';
price?: string | null;
iconUrl?: string;
imageUrl?: string;
usageData?: {
consume: boolean;
effects: Array<{ type: string;[key: string]: any }>;
} | null;
}
export interface UpdateItemData extends Partial<CreateItemData> { }
export function useItems(initialFilters: ItemFilters = {}) {
const [items, setItems] = useState<ItemWithUsage[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [filters, setFilters] = useState<ItemFilters>(initialFilters);
const fetchItems = useCallback(async (newFilters?: ItemFilters) => {
setLoading(true);
try {
const params = new URLSearchParams();
const activeFilters = newFilters ?? filters;
if (activeFilters.search) params.set("search", activeFilters.search);
if (activeFilters.type) params.set("type", activeFilters.type);
if (activeFilters.rarity) params.set("rarity", activeFilters.rarity);
if (activeFilters.limit) params.set("limit", String(activeFilters.limit));
if (activeFilters.offset) params.set("offset", String(activeFilters.offset));
const response = await fetch(`/api/items?${params.toString()}`);
if (!response.ok) throw new Error("Failed to fetch items");
const data: ItemsResponse = await response.json();
setItems(data.items);
setTotal(data.total);
} catch (error) {
console.error("Error fetching items:", error);
toast.error("Failed to load items", {
description: error instanceof Error ? error.message : "Unknown error"
});
} finally {
setLoading(false);
}
}, [filters]);
useEffect(() => {
fetchItems();
}, [fetchItems]);
const updateFilters = useCallback((newFilters: Partial<ItemFilters>) => {
setFilters(prev => ({ ...prev, ...newFilters }));
}, []);
const clearFilters = useCallback(() => {
setFilters({});
}, []);
return {
items,
total,
loading,
filters,
fetchItems,
updateFilters,
clearFilters,
};
}
export function useItem(id: number | null) {
const [item, setItem] = useState<ItemWithUsage | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchItem = useCallback(async () => {
if (id === null) {
setItem(null);
return;
}
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/items/${id}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error("Item not found");
}
throw new Error("Failed to fetch item");
}
const data = await response.json();
setItem(data);
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
setError(message);
toast.error("Failed to load item", { description: message });
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
fetchItem();
}, [fetchItem]);
return { item, loading, error, refetch: fetchItem };
}
export function useCreateItem() {
const [loading, setLoading] = useState(false);
const createItem = useCallback(async (data: CreateItemData, imageFile?: File): Promise<ItemWithUsage | null> => {
setLoading(true);
try {
let response: Response;
if (imageFile) {
// Multipart form with image
const formData = new FormData();
formData.append("data", JSON.stringify(data));
formData.append("image", imageFile);
response = await fetch("/api/items", {
method: "POST",
body: formData,
});
} else {
// JSON-only request
response = await fetch("/api/items", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to create item");
}
const result = await response.json();
toast.success("Item created", {
description: `"${result.item.name}" has been created successfully.`
});
return result.item;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
toast.error("Failed to create item", { description: message });
return null;
} finally {
setLoading(false);
}
}, []);
return { createItem, loading };
}
export function useUpdateItem() {
const [loading, setLoading] = useState(false);
const updateItem = useCallback(async (id: number, data: UpdateItemData): Promise<ItemWithUsage | null> => {
setLoading(true);
try {
const response = await fetch(`/api/items/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to update item");
}
const result = await response.json();
toast.success("Item updated", {
description: `"${result.item.name}" has been updated successfully.`
});
return result.item;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
toast.error("Failed to update item", { description: message });
return null;
} finally {
setLoading(false);
}
}, []);
return { updateItem, loading };
}
export function useDeleteItem() {
const [loading, setLoading] = useState(false);
const deleteItem = useCallback(async (id: number, name?: string): Promise<boolean> => {
setLoading(true);
try {
const response = await fetch(`/api/items/${id}`, {
method: "DELETE",
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to delete item");
}
toast.success("Item deleted", {
description: name ? `"${name}" has been deleted.` : "Item has been deleted."
});
return true;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
toast.error("Failed to delete item", { description: message });
return false;
} finally {
setLoading(false);
}
}, []);
return { deleteItem, loading };
}
export function useUploadItemIcon() {
const [loading, setLoading] = useState(false);
const uploadIcon = useCallback(async (itemId: number, imageFile: File): Promise<ItemWithUsage | null> => {
setLoading(true);
try {
const formData = new FormData();
formData.append("image", imageFile);
const response = await fetch(`/api/items/${itemId}/icon`, {
method: "POST",
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.error || "Failed to upload icon");
}
const result = await response.json();
toast.success("Icon uploaded", {
description: "Item icon has been updated successfully."
});
return result.item;
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error";
toast.error("Failed to upload icon", { description: message });
return null;
} finally {
setLoading(false);
}
}, []);
return { uploadIcon, loading };
}