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

View File

@@ -14,6 +14,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
@@ -124,6 +125,8 @@
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slider": ["@radix-ui/react-slider@1.3.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JPYb1GuM1bxfjMRlNLE+BcmBC8onfCi60Blk7OBqi2MLTFdS+8401U4uFjnwkOr49BLmXxLC6JHkvAsx5OJvHw=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="],

View File

@@ -18,6 +18,7 @@
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",

View File

@@ -0,0 +1,397 @@
/**
* EffectEditor Component
* Dynamic form for adding/editing item effects with all 7 effect types.
*/
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { LootTableBuilder } from "@/components/loot-table-builder";
import {
Plus,
Trash2,
Sparkles,
Coins,
MessageSquare,
Zap,
Clock,
Palette,
Package,
} from "lucide-react";
import { cn } from "@/lib/utils";
// Effect types matching the backend
const EFFECT_TYPES = [
{ value: "ADD_XP", label: "Add XP", icon: Sparkles, color: "text-blue-400" },
{ value: "ADD_BALANCE", label: "Add Balance", icon: Coins, color: "text-amber-400" },
{ value: "REPLY_MESSAGE", label: "Reply Message", icon: MessageSquare, color: "text-green-400" },
{ value: "XP_BOOST", label: "XP Boost", icon: Zap, color: "text-purple-400" },
{ value: "TEMP_ROLE", label: "Temporary Role", icon: Clock, color: "text-orange-400" },
{ value: "COLOR_ROLE", label: "Color Role", icon: Palette, color: "text-pink-400" },
{ value: "LOOTBOX", label: "Lootbox", icon: Package, color: "text-yellow-400" },
];
interface Effect {
type: string;
[key: string]: any;
}
interface EffectEditorProps {
effects: Effect[];
onChange: (effects: Effect[]) => void;
}
const getDefaultEffect = (type: string): Effect => {
switch (type) {
case "ADD_XP":
return { type, amount: 100 };
case "ADD_BALANCE":
return { type, amount: 100 };
case "REPLY_MESSAGE":
return { type, message: "" };
case "XP_BOOST":
return { type, multiplier: 2, durationMinutes: 60 };
case "TEMP_ROLE":
return { type, roleId: "", durationMinutes: 60 };
case "COLOR_ROLE":
return { type, roleId: "" };
case "LOOTBOX":
return { type, pool: [] };
default:
return { type };
}
};
const getEffectSummary = (effect: Effect): string => {
switch (effect.type) {
case "ADD_XP":
return `+${effect.amount} XP`;
case "ADD_BALANCE":
return `+${effect.amount} coins`;
case "REPLY_MESSAGE":
return effect.message ? `"${effect.message.slice(0, 30)}..."` : "No message";
case "XP_BOOST":
return `${effect.multiplier}x for ${effect.durationMinutes || effect.durationHours * 60 || effect.durationSeconds / 60}m`;
case "TEMP_ROLE":
return `Role for ${effect.durationMinutes || effect.durationHours * 60 || effect.durationSeconds / 60}m`;
case "COLOR_ROLE":
return effect.roleId ? `Role: ${effect.roleId}` : "No role set";
case "LOOTBOX":
return `${effect.pool?.length || 0} drops`;
default:
return effect.type;
}
};
export function EffectEditor({ effects, onChange }: EffectEditorProps) {
const [expandedItems, setExpandedItems] = useState<string[]>([]);
const addEffect = useCallback(() => {
const newEffect = getDefaultEffect("ADD_XP");
const newEffects = [...effects, newEffect];
onChange(newEffects);
setExpandedItems(prev => [...prev, `effect-${newEffects.length - 1}`]);
}, [effects, onChange]);
const removeEffect = useCallback((index: number) => {
const newEffects = effects.filter((_, i) => i !== index);
onChange(newEffects);
}, [effects, onChange]);
const updateEffect = useCallback((index: number, updates: Partial<Effect>) => {
const newEffects = effects.map((effect, i) =>
i === index ? { ...effect, ...updates } : effect
);
onChange(newEffects);
}, [effects, onChange]);
const changeEffectType = useCallback((index: number, newType: string) => {
const newEffects = effects.map((effect, i) =>
i === index ? getDefaultEffect(newType) : effect
);
onChange(newEffects);
}, [effects, onChange]);
if (effects.length === 0) {
return (
<div className="border border-dashed border-border/50 rounded-lg p-6 text-center">
<p className="text-sm text-muted-foreground mb-3">
No effects added yet
</p>
<Button type="button" variant="outline" size="sm" onClick={addEffect}>
<Plus className="h-4 w-4 mr-2" />
Add Effect
</Button>
</div>
);
}
return (
<div className="space-y-3">
<Accordion
type="multiple"
value={expandedItems}
onValueChange={setExpandedItems}
className="space-y-2"
>
{effects.map((effect, index) => {
const effectType = EFFECT_TYPES.find(t => t.value === effect.type);
const Icon = effectType?.icon || Sparkles;
return (
<AccordionItem
key={index}
value={`effect-${index}`}
className="border border-border/50 rounded-lg bg-card/30 overflow-hidden"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline hover:bg-muted/30">
<div className="flex items-center gap-3 flex-1">
<Icon className={cn("h-4 w-4", effectType?.color)} />
<span className="font-medium">
{effectType?.label || effect.type}
</span>
<span className="text-sm text-muted-foreground">
{getEffectSummary(effect)}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-4 pt-2">
{/* Effect Type Selector */}
<div className="space-y-2">
<Label>Effect Type</Label>
<Select
value={effect.type}
onValueChange={(value) => changeEffectType(index, value)}
>
<SelectTrigger className="bg-background/50">
<SelectValue />
</SelectTrigger>
<SelectContent>
{EFFECT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<type.icon className={cn("h-4 w-4", type.color)} />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Dynamic Fields Based on Effect Type */}
{effect.type === "ADD_XP" && (
<div className="space-y-2">
<Label>Amount</Label>
<Input
type="number"
value={effect.amount || 0}
onChange={(e) => updateEffect(index, { amount: parseInt(e.target.value) || 0 })}
className="bg-background/50"
/>
</div>
)}
{effect.type === "ADD_BALANCE" && (
<div className="space-y-2">
<Label>Amount</Label>
<div className="relative">
<Input
type="number"
value={effect.amount || 0}
onChange={(e) => updateEffect(index, { amount: parseInt(e.target.value) || 0 })}
className="bg-background/50 pr-10"
/>
<Coins className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-amber-400" /></div>
</div>
)}
{effect.type === "REPLY_MESSAGE" && (
<div className="space-y-2">
<Label>Message</Label>
<Textarea
value={effect.message || ""}
onChange={(e) => updateEffect(index, { message: e.target.value })}
placeholder="The message to display when used..."
className="bg-background/50 min-h-[80px]"
/>
</div>
)}
{effect.type === "XP_BOOST" && (
<>
<div className="space-y-2">
<Label>Multiplier</Label>
<Input
type="number"
step="0.1"
value={effect.multiplier || 2}
onChange={(e) => updateEffect(index, { multiplier: parseFloat(e.target.value) || 2 })}
className="bg-background/50"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-2">
<Label>Hours</Label>
<Input
type="number"
value={effect.durationHours || 0}
onChange={(e) => updateEffect(index, {
durationHours: parseInt(e.target.value) || 0,
durationMinutes: undefined,
durationSeconds: undefined,
})}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label>Minutes</Label>
<Input
type="number"
value={effect.durationMinutes || 0}
onChange={(e) => updateEffect(index, {
durationMinutes: parseInt(e.target.value) || 0,
durationHours: undefined,
durationSeconds: undefined,
})}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label>Seconds</Label>
<Input
type="number"
value={effect.durationSeconds || 0}
onChange={(e) => updateEffect(index, {
durationSeconds: parseInt(e.target.value) || 0,
durationHours: undefined,
durationMinutes: undefined,
})}
className="bg-background/50"
/>
</div>
</div>
</>
)}
{effect.type === "TEMP_ROLE" && (
<>
<div className="space-y-2">
<Label>Role ID</Label>
<Input
value={effect.roleId || ""}
onChange={(e) => updateEffect(index, { roleId: e.target.value })}
placeholder="Discord Role ID"
className="bg-background/50"
/>
</div>
<div className="grid grid-cols-3 gap-2">
<div className="space-y-2">
<Label>Hours</Label>
<Input
type="number"
value={effect.durationHours || 0}
onChange={(e) => updateEffect(index, {
durationHours: parseInt(e.target.value) || 0,
durationMinutes: undefined,
durationSeconds: undefined,
})}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label>Minutes</Label>
<Input
type="number"
value={effect.durationMinutes || 0}
onChange={(e) => updateEffect(index, {
durationMinutes: parseInt(e.target.value) || 0,
durationHours: undefined,
durationSeconds: undefined,
})}
className="bg-background/50"
/>
</div>
<div className="space-y-2">
<Label>Seconds</Label>
<Input
type="number"
value={effect.durationSeconds || 0}
onChange={(e) => updateEffect(index, {
durationSeconds: parseInt(e.target.value) || 0,
durationHours: undefined,
durationMinutes: undefined,
})}
className="bg-background/50"
/>
</div>
</div>
</>
)}
{effect.type === "COLOR_ROLE" && (
<div className="space-y-2">
<Label>Role ID</Label>
<Input
value={effect.roleId || ""}
onChange={(e) => updateEffect(index, { roleId: e.target.value })}
placeholder="Discord Role ID for color"
className="bg-background/50"
/>
</div>
)}
{effect.type === "LOOTBOX" && (
<div className="space-y-2">
<Label>Loot Table</Label>
<LootTableBuilder
pool={effect.pool || []}
onChange={(pool: unknown[]) => updateEffect(index, { pool })}
/>
</div>
)}
{/* Delete Button */}
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => removeEffect(index)}
className="mt-2"
>
<Trash2 className="h-4 w-4 mr-2" />
Remove Effect
</Button>
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
<Button type="button" variant="outline" size="sm" onClick={addEffect}>
<Plus className="h-4 w-4 mr-2" />
Add Effect
</Button>
</div>
);
}
export default EffectEditor;

View File

@@ -0,0 +1,230 @@
/**
* ImageUploader Component
* Drag-and-drop image upload zone with preview.
*/
import { useState, useCallback, useRef } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Upload, X, Image as ImageIcon, Loader2, AlertCircle } from "lucide-react";
interface ImageUploaderProps {
existingUrl?: string | null;
onFileSelect: (file: File | null) => void;
disabled?: boolean;
maxSizeMB?: number;
}
const ACCEPTED_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
const MAX_SIZE_DEFAULT = 2; // 2MB
export function ImageUploader({
existingUrl,
onFileSelect,
disabled,
maxSizeMB = MAX_SIZE_DEFAULT,
}: ImageUploaderProps) {
const [previewUrl, setPreviewUrl] = useState<string | null>(existingUrl || null);
const [isDragging, setIsDragging] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const validateFile = useCallback((file: File): string | null => {
if (!ACCEPTED_TYPES.includes(file.type)) {
return "Invalid file type. Please use PNG, JPEG, WebP, or GIF.";
}
if (file.size > maxSizeMB * 1024 * 1024) {
return `File too large. Maximum size is ${maxSizeMB}MB.`;
}
return null;
}, [maxSizeMB]);
const handleFile = useCallback((file: File) => {
setError(null);
const validationError = validateFile(file);
if (validationError) {
setError(validationError);
return;
}
setIsLoading(true);
// Create preview URL
const reader = new FileReader();
reader.onload = () => {
setPreviewUrl(reader.result as string);
setIsLoading(false);
};
reader.onerror = () => {
setError("Failed to read file");
setIsLoading(false);
};
reader.readAsDataURL(file);
onFileSelect(file);
}, [onFileSelect, validateFile]);
const handleDragEnter = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
if (!disabled) {
setIsDragging(true);
}
}, [disabled]);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
if (disabled) return;
const files = e.dataTransfer.files;
const file = files[0];
if (file) {
handleFile(file);
}
}, [disabled, handleFile]);
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
const file = files?.[0];
if (file) {
handleFile(file);
}
}, [handleFile]);
const handleRemove = useCallback(() => {
setPreviewUrl(null);
setError(null);
onFileSelect(null);
if (inputRef.current) {
inputRef.current.value = "";
}
}, [onFileSelect]);
const handleClick = useCallback(() => {
if (!disabled) {
inputRef.current?.click();
}
}, [disabled]);
// Show preview state
if (previewUrl) {
return (
<div className="relative">
<div className="relative aspect-square max-w-[200px] rounded-lg overflow-hidden border border-border/50 bg-muted/30">
<img
src={previewUrl}
alt="Item preview"
className="w-full h-full object-contain"
/>
{!disabled && (
<div className="absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleClick}
>
Replace
</Button>
<Button
type="button"
variant="destructive"
size="sm"
onClick={handleRemove}
>
<X className="h-4 w-4" />
</Button>
</div>
)}
</div>
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
onChange={handleInputChange}
className="hidden"
/>
</div>
);
}
// Show upload zone
return (
<div className="space-y-2">
<div
onClick={handleClick}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDragOver={handleDragOver}
onDrop={handleDrop}
className={cn(
"relative flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg transition-colors cursor-pointer",
isDragging && "border-primary bg-primary/5",
error && "border-destructive bg-destructive/5",
!isDragging && !error && "border-border/50 hover:border-border bg-muted/10",
disabled && "opacity-50 cursor-not-allowed"
)}
>
{isLoading ? (
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
) : error ? (
<AlertCircle className="h-8 w-8 text-destructive" />
) : (
<div className="flex flex-col items-center gap-2 text-center">
{isDragging ? (
<Upload className="h-8 w-8 text-primary animate-bounce" />
) : (
<ImageIcon className="h-8 w-8 text-muted-foreground" />
)}
<div className="text-sm text-muted-foreground">
{isDragging ? (
<p className="text-primary font-medium">Drop to upload</p>
) : (
<>
<p className="font-medium">Drop image or click to browse</p>
<p className="text-xs mt-1">
PNG, JPEG, WebP, GIF Max {maxSizeMB}MB
</p>
</>
)}
</div>
</div>
)}
<input
ref={inputRef}
type="file"
accept={ACCEPTED_TYPES.join(",")}
onChange={handleInputChange}
className="hidden"
disabled={disabled}
/>
</div>
{error && (
<p className="text-sm text-destructive flex items-center gap-1">
<AlertCircle className="h-4 w-4" />
{error}
</p>
)}
</div>
);
}
export default ImageUploader;

View File

@@ -0,0 +1,70 @@
/**
* ItemFormSheet Component
* Sheet/Drawer wrapper for ItemForm, used for create/edit from the list view.
*/
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet";
import { ItemForm } from "@/components/item-form";
import type { ItemWithUsage } from "@/hooks/use-items";
import { Package, Pencil } from "lucide-react";
interface ItemFormSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
item: ItemWithUsage | null;
onSaved: () => void;
}
export function ItemFormSheet({ open, onOpenChange, item, onSaved }: ItemFormSheetProps) {
const isEditMode = item !== null;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
className="w-full sm:max-w-xl overflow-y-auto p-0"
side="right"
>
{/* Primary accent bar */}
<div className="h-1.5 bg-primary w-full" />
<div className="p-6">
<SheetHeader className="mb-8">
<SheetTitle className="text-2xl font-bold text-primary flex items-center gap-3">
{isEditMode ? (
<>
<Pencil className="h-5 w-5" />
Edit Item
</>
) : (
<>
<Package className="h-5 w-5" />
Create Item
</>
)}
</SheetTitle>
<SheetDescription className="text-muted-foreground">
{isEditMode
? `Update the details for "${item.name}" below.`
: "Configure a new item for the Aurora RPG."
}
</SheetDescription>
</SheetHeader>
<ItemForm
initialData={item}
onSuccess={onSaved}
onCancel={() => onOpenChange(false)}
/>
</div>
</SheetContent>
</Sheet>
);
}
export default ItemFormSheet;

View File

@@ -0,0 +1,381 @@
/**
* ItemForm Component
* Main form for creating and editing items with all fields and effects.
*/
import { useEffect, useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { ImageUploader } from "@/components/image-uploader";
import { EffectEditor } from "@/components/effect-editor";
import { useCreateItem, useUpdateItem, type ItemWithUsage, type CreateItemData } from "@/hooks/use-items";
import { Loader2, Coins, FileText, Image, Zap } from "lucide-react";
// Form schema
const itemFormSchema = z.object({
name: z.string().min(1, "Name is required").max(255, "Name is too long"),
description: z.string().optional().nullable(),
rarity: z.enum(["Common", "Uncommon", "Rare", "Epic", "Legendary"]),
type: z.enum(["MATERIAL", "CONSUMABLE", "EQUIPMENT", "QUEST"]),
price: z.string().optional().nullable(),
consume: z.boolean(),
effects: z.array(z.any()).default([]),
});
type FormValues = z.infer<typeof itemFormSchema>;
interface ItemFormProps {
initialData?: ItemWithUsage | null;
onSuccess: () => void;
onCancel: () => void;
}
const ITEM_TYPES = [
{ value: "MATERIAL", label: "Material", description: "Raw materials and crafting components" },
{ value: "CONSUMABLE", label: "Consumable", description: "Items that can be used and consumed" },
{ value: "EQUIPMENT", label: "Equipment", description: "Equippable items with passive effects" },
{ value: "QUEST", label: "Quest", description: "Quest-related items" },
];
const RARITIES = [
{ value: "Common", label: "Common" },
{ value: "Uncommon", label: "Uncommon" },
{ value: "Rare", label: "Rare" },
{ value: "Epic", label: "Epic" },
{ value: "Legendary", label: "Legendary" },
];
export function ItemForm({ initialData, onSuccess, onCancel }: ItemFormProps) {
const isEditMode = !!initialData;
const { createItem, loading: createLoading } = useCreateItem();
const { updateItem, loading: updateLoading } = useUpdateItem();
const [imageFile, setImageFile] = useState<File | null>(null);
const [existingImageUrl, setExistingImageUrl] = useState<string | null>(null);
const form = useForm({
resolver: zodResolver(itemFormSchema),
defaultValues: {
name: "",
description: "",
rarity: "Common" as const,
type: "MATERIAL" as const,
price: "",
consume: false,
effects: [] as unknown[],
},
});
// Populate form when editing
useEffect(() => {
if (initialData) {
form.reset({
name: initialData.name,
description: initialData.description || "",
rarity: (initialData.rarity as FormValues["rarity"]) || "Common",
type: (initialData.type as FormValues["type"]) || "MATERIAL",
price: initialData.price ? String(initialData.price) : "",
consume: initialData.usageData?.consume ?? false,
effects: initialData.usageData?.effects ?? [],
});
// Set existing image URL for preview
if (initialData.iconUrl && !initialData.iconUrl.includes("placeholder")) {
setExistingImageUrl(initialData.iconUrl);
}
}
}, [initialData, form]);
const onSubmit = async (values: FormValues) => {
const itemData: CreateItemData = {
name: values.name,
description: values.description || null,
rarity: values.rarity,
type: values.type,
price: values.price || null,
usageData: {
consume: values.consume,
effects: values.effects,
},
};
if (isEditMode && initialData) {
const result = await updateItem(initialData.id, itemData);
if (result) {
onSuccess();
}
} else {
const result = await createItem(itemData, imageFile || undefined);
if (result) {
onSuccess();
}
}
};
const isLoading = createLoading || updateLoading;
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* Basic Info Section */}
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-4 border-b border-border/30">
<CardTitle className="text-base font-medium flex items-center gap-2">
<FileText className="h-4 w-4 text-primary" />
Basic Info
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-5">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Name *</FormLabel>
<FormControl>
<Input
placeholder="Health Potion"
{...field}
className="bg-background/50"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
placeholder="A mystical potion that restores vitality..."
{...field}
value={field.value ?? ""}
className="bg-background/50 min-h-[80px]"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={form.control}
name="type"
render={({ field }) => (
<FormItem>
<FormLabel>Type *</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="bg-background/50">
<SelectValue placeholder="Select type" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ITEM_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="rarity"
render={({ field }) => (
<FormItem>
<FormLabel>Rarity</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger className="bg-background/50">
<SelectValue placeholder="Select rarity" />
</SelectTrigger>
</FormControl>
<SelectContent>
{RARITIES.map((rarity) => (
<SelectItem key={rarity.value} value={rarity.value}>
{rarity.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
</CardContent>
</Card>
{/* Economy Section */}
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-4 border-b border-border/30">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Coins className="h-4 w-4 text-amber-400" />
Economy
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<FormField
control={form.control}
name="price"
render={({ field }) => (
<FormItem>
<FormLabel>Shop Price</FormLabel>
<FormControl>
<div className="relative">
<Input
type="number"
placeholder="0"
{...field}
value={field.value ?? ""}
className="bg-background/50 pr-10"
/>
<Coins className="absolute right-3 top-1/2 -translate-y-1/2 h-4 w-4 text-amber-400" />
</div>
</FormControl>
<FormDescription>
Leave empty if item cannot be purchased in shops
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Visuals Section */}
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-4 border-b border-border/30">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Image className="h-4 w-4 text-purple-400" />
Visuals
</CardTitle>
</CardHeader>
<CardContent className="pt-5">
<ImageUploader
existingUrl={existingImageUrl}
onFileSelect={setImageFile}
disabled={isEditMode}
/>
{isEditMode && (
<p className="text-xs text-muted-foreground mt-2">
To update the image, use the icon upload in the items table after saving.
</p>
)}
</CardContent>
</Card>
{/* Usage Section */}
<Card className="glass-card overflow-hidden">
<CardHeader className="pb-4 border-b border-border/30">
<CardTitle className="text-base font-medium flex items-center gap-2">
<Zap className="h-4 w-4 text-green-400" />
Usage
</CardTitle>
</CardHeader>
<CardContent className="space-y-5 pt-5">
<FormField
control={form.control}
name="consume"
render={({ field }) => (
<FormItem className="flex items-center justify-between rounded-lg border border-border/50 p-4">
<div className="space-y-0.5">
<FormLabel className="text-base">Consume on Use</FormLabel>
<FormDescription>
Remove one from inventory when used
</FormDescription>
</div>
<FormControl>
<Switch
checked={field.value}
onCheckedChange={field.onChange}
/>
</FormControl>
</FormItem>
)}
/>
<Separator className="my-4" />
<FormField
control={form.control}
name="effects"
render={({ field }) => (
<FormItem>
<FormLabel>Effects</FormLabel>
<FormDescription className="mb-3">
Add effects that trigger when the item is used
</FormDescription>
<FormControl>
<EffectEditor
effects={field.value ?? []}
onChange={field.onChange}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CardContent>
</Card>
{/* Actions */}
<div className="flex gap-4 pt-6">
<Button
type="button"
variant="outline"
onClick={onCancel}
className="flex-1 py-6 text-base font-semibold"
disabled={isLoading}
>
Cancel
</Button>
<Button
type="submit"
className="flex-1 bg-primary text-primary-foreground hover:glow-primary active-press py-6 text-base font-semibold"
disabled={isLoading}
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditMode ? "Update Item" : "Create Item"}
</Button>
</div>
</form>
</Form>
);
}
export default ItemForm;

View File

@@ -0,0 +1,140 @@
/**
* ItemsFilter Component
* Filter bar for the items list with search, type, and rarity filters.
*/
import { useState, useEffect, useCallback } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Search, X, Filter, Package, Zap, Hammer, Scroll, Gem } from "lucide-react";
import { cn } from "@/lib/utils";
import type { ItemFilters } from "@/hooks/use-items";
interface ItemsFilterProps {
filters: ItemFilters;
onFilterChange: (filters: Partial<ItemFilters>) => void;
onClearFilters: () => void;
}
const ITEM_TYPES = [
{ value: "MATERIAL", label: "Material", icon: Package },
{ value: "CONSUMABLE", label: "Consumable", icon: Zap },
{ value: "EQUIPMENT", label: "Equipment", icon: Hammer },
{ value: "QUEST", label: "Quest", icon: Scroll },
];
const RARITIES = [
{ value: "Common", label: "Common", color: "text-zinc-400" },
{ value: "Uncommon", label: "Uncommon", color: "text-green-400" },
{ value: "Rare", label: "Rare", color: "text-blue-400" },
{ value: "Epic", label: "Epic", color: "text-purple-400" },
{ value: "Legendary", label: "Legendary", color: "text-amber-400" },
];
export function ItemsFilter({ filters, onFilterChange, onClearFilters }: ItemsFilterProps) {
const [searchValue, setSearchValue] = useState(filters.search || "");
// Debounced search
useEffect(() => {
const timeout = setTimeout(() => {
if (searchValue !== filters.search) {
onFilterChange({ search: searchValue || undefined });
}
}, 300);
return () => clearTimeout(timeout);
}, [searchValue, filters.search, onFilterChange]);
const handleTypeChange = useCallback((value: string) => {
onFilterChange({ type: value === "__all__" ? undefined : value });
}, [onFilterChange]);
const handleRarityChange = useCallback((value: string) => {
onFilterChange({ rarity: value === "__all__" ? undefined : value });
}, [onFilterChange]);
const hasActiveFilters = !!(filters.search || filters.type || filters.rarity);
return (
<div className="flex flex-col sm:flex-row gap-3 items-stretch sm:items-center">
{/* Search Input */}
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search items..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="pl-9 bg-card/50 border-border/50"
/>
</div>
{/* Type Filter */}
<Select
value={filters.type || "__all__"}
onValueChange={handleTypeChange}
>
<SelectTrigger className="w-full sm:w-40 bg-card/50 border-border/50">
<Filter className="h-4 w-4 mr-2 text-muted-foreground" />
<SelectValue placeholder="Type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All Types</SelectItem>
{ITEM_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<type.icon className="h-4 w-4 text-muted-foreground" />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Rarity Filter */}
<Select
value={filters.rarity || "__all__"}
onValueChange={handleRarityChange}
>
<SelectTrigger className="w-full sm:w-40 bg-card/50 border-border/50">
<SelectValue placeholder="Rarity" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__all__">All Rarities</SelectItem>
{RARITIES.map((rarity) => (
<SelectItem key={rarity.value} value={rarity.value}>
<div className="flex items-center gap-2">
<Gem className={cn("h-4 w-4", rarity.color)} />
<span className={rarity.color}>{rarity.label}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Clear Button */}
{hasActiveFilters && (
<Button
variant="ghost"
size="sm"
onClick={() => {
setSearchValue("");
onClearFilters();
}}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4 mr-1" />
Clear
</Button>
)}
</div>
);
}
export default ItemsFilter;

View File

@@ -0,0 +1,342 @@
/**
* ItemsTable Component
* Data table displaying all items with key information and actions.
*/
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { RarityBadge } from "@/components/rarity-badge";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
MoreHorizontal,
Pencil,
Trash2,
RefreshCw,
Package,
ChevronUp,
ChevronDown,
Coins,
} from "lucide-react";
import type { ItemWithUsage } from "@/hooks/use-items";
import { cn } from "@/lib/utils";
interface ItemsTableProps {
items: ItemWithUsage[];
isLoading: boolean;
isRefreshing?: boolean;
total: number;
onRefresh: () => void;
onEdit: (item: ItemWithUsage) => void;
onDelete: (item: ItemWithUsage) => void;
}
type SortField = 'name' | 'type' | 'rarity' | 'price';
type SortDirection = 'asc' | 'desc';
const TYPE_LABELS: Record<string, string> = {
MATERIAL: 'Material',
CONSUMABLE: 'Consumable',
EQUIPMENT: 'Equipment',
QUEST: 'Quest',
};
const TYPE_COLORS: Record<string, string> = {
MATERIAL: 'text-zinc-400',
CONSUMABLE: 'text-green-400',
EQUIPMENT: 'text-blue-400',
QUEST: 'text-yellow-400',
};
export function ItemsTable({
items,
isLoading,
isRefreshing,
total,
onRefresh,
onEdit,
onDelete,
}: ItemsTableProps) {
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<SortDirection>('asc');
const [deleteConfirm, setDeleteConfirm] = useState<number | null>(null);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection(prev => prev === 'asc' ? 'desc' : 'asc');
} else {
setSortField(field);
setSortDirection('asc');
}
};
const sortedItems = [...items].sort((a, b) => {
const direction = sortDirection === 'asc' ? 1 : -1;
switch (sortField) {
case 'name':
return direction * a.name.localeCompare(b.name);
case 'type':
return direction * (a.type || '').localeCompare(b.type || '');
case 'rarity': {
const rarityOrder = ['Common', 'Uncommon', 'Rare', 'Epic', 'Legendary'];
const aIndex = rarityOrder.indexOf(a.rarity || 'Common');
const bIndex = rarityOrder.indexOf(b.rarity || 'Common');
return direction * (aIndex - bIndex);
}
case 'price': {
const aPrice = a.price ?? 0n;
const bPrice = b.price ?? 0n;
if (aPrice < bPrice) return -direction;
if (aPrice > bPrice) return direction;
return 0;
}
default:
return 0;
}
});
const SortIcon = ({ field }: { field: SortField }) => {
if (sortField !== field) return null;
return sortDirection === 'asc'
? <ChevronUp className="h-4 w-4 ml-1" />
: <ChevronDown className="h-4 w-4 ml-1" />;
};
const handleDeleteClick = (item: ItemWithUsage) => {
if (deleteConfirm === item.id) {
onDelete(item);
setDeleteConfirm(null);
} else {
setDeleteConfirm(item.id);
// Auto-reset after 3 seconds
setTimeout(() => setDeleteConfirm(null), 3000);
}
};
if (isLoading) {
return (
<Card className="bg-card/50 border-border/50">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-medium flex items-center gap-2">
<Package className="h-5 w-5" />
Items
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-10 w-10 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-20" />
</div>
<Skeleton className="h-6 w-16 rounded-full" />
</div>
))}
</div>
</CardContent>
</Card>
);
}
if (items.length === 0) {
return (
<Card className="bg-card/50 border-border/50">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg font-medium flex items-center gap-2">
<Package className="h-5 w-5" />
Items
</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
</Button>
</CardHeader>
<CardContent>
<div className="flex flex-col items-center justify-center py-12 text-center">
<Package className="h-12 w-12 text-muted-foreground/50 mb-4" />
<p className="text-muted-foreground">No items found</p>
<p className="text-sm text-muted-foreground/70">
Create your first item to get started
</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-card/50 border-border/50">
<CardHeader className="flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<CardTitle className="text-lg font-medium flex items-center gap-2">
<Package className="h-5 w-5" />
Items
</CardTitle>
<span className="text-sm text-muted-foreground">
({total} total)
</span>
</div>
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
</Button>
</CardHeader>
<CardContent>
{/* Table Header */}
<div className="hidden md:grid grid-cols-[48px_1fr_120px_100px_100px_48px] gap-4 px-4 py-2 text-sm font-medium text-muted-foreground border-b border-border/50">
<div></div>
<button
onClick={() => handleSort('name')}
className="flex items-center hover:text-foreground transition-colors text-left"
>
Name
<SortIcon field="name" />
</button>
<button
onClick={() => handleSort('type')}
className="flex items-center hover:text-foreground transition-colors"
>
Type
<SortIcon field="type" />
</button>
<button
onClick={() => handleSort('rarity')}
className="flex items-center hover:text-foreground transition-colors"
>
Rarity
<SortIcon field="rarity" />
</button>
<button
onClick={() => handleSort('price')}
className="flex items-center hover:text-foreground transition-colors"
>
Price
<SortIcon field="price" />
</button>
<div></div>
</div>
{/* Table Body */}
<div className="divide-y divide-border/30">
{sortedItems.map((item) => (
<div
key={item.id}
id={`item-row-${item.id}`}
className="grid grid-cols-[48px_1fr_auto] md:grid-cols-[48px_1fr_120px_100px_100px_48px] gap-4 px-4 py-3 items-center hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => onEdit(item)}
>
{/* Icon */}
<div className="relative h-10 w-10 rounded-lg overflow-hidden bg-muted/50">
{item.iconUrl && !item.iconUrl.includes('placeholder') ? (
<img
src={item.iconUrl}
alt={item.name}
className="h-full w-full object-cover"
onError={(e) => {
(e.target as HTMLImageElement).src = '';
(e.target as HTMLImageElement).classList.add('hidden');
}}
/>
) : (
<div className="h-full w-full flex items-center justify-center">
<Package className="h-5 w-5 text-muted-foreground" />
</div>
)}
</div>
{/* Name & Description */}
<div className="min-w-0">
<p className="font-medium truncate">{item.name}</p>
{item.description && (
<p className="text-sm text-muted-foreground truncate">
{item.description}
</p>
)}
</div>
{/* Type (hidden on mobile) */}
<div className="hidden md:block">
<span className={cn("text-sm", TYPE_COLORS[item.type || 'MATERIAL'])}>
{TYPE_LABELS[item.type || 'MATERIAL']}
</span>
</div>
{/* Rarity (hidden on mobile) */}
<div className="hidden md:block">
<RarityBadge rarity={item.rarity || 'Common'} size="sm" />
</div>
{/* Price (hidden on mobile) */}
<div className="hidden md:block text-sm">
{item.price ? (
<span className="text-amber-400 flex items-center gap-1">
{item.price.toLocaleString()}
<Coins className="h-3.5 w-3.5" />
</span>
) : (
<span className="text-muted-foreground">-</span>
)}
</div>
{/* Mobile: badges */}
<div className="flex md:hidden items-center gap-2">
<RarityBadge rarity={item.rarity || 'Common'} size="sm" />
</div>
{/* Actions */}
<div onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => onEdit(item)}>
<Pencil className="h-4 w-4 mr-2" />
Edit
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteClick(item)}
className={cn(
deleteConfirm === item.id
? "bg-destructive text-destructive-foreground"
: "text-destructive focus:text-destructive"
)}
>
<Trash2 className="h-4 w-4 mr-2" />
{deleteConfirm === item.id ? "Click to confirm" : "Delete"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export default ItemsTable;

View File

@@ -0,0 +1,275 @@
/**
* LootTableBuilder Component
* Visual editor for configuring lootbox drop pools with items and currencies.
*/
import { useState, useCallback, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { Plus, Trash2, Package, Coins, Sparkles, GripVertical, Percent } from "lucide-react";
import { cn } from "@/lib/utils";
// Loot drop types
type LootType = "ITEM" | "BALANCE" | "XP";
interface LootDrop {
type: LootType;
itemId?: number;
itemName?: string;
amount?: number | [number, number]; // Single value or [min, max]
weight: number;
}
interface LootTableBuilderProps {
pool: LootDrop[];
onChange: (pool: LootDrop[]) => void;
}
const LOOT_TYPES = [
{ value: "ITEM" as LootType, label: "Item", icon: Package, color: "text-purple-400" },
{ value: "BALANCE" as LootType, label: "Balance", icon: Coins, color: "text-amber-400" },
{ value: "XP" as LootType, label: "XP", icon: Sparkles, color: "text-blue-400" },
];
const getDefaultDrop = (type: LootType): LootDrop => {
switch (type) {
case "ITEM":
return { type: "ITEM", itemId: 0, itemName: "", weight: 10 };
case "BALANCE":
return { type: "BALANCE", amount: [100, 500], weight: 30 };
case "XP":
return { type: "XP", amount: [50, 200], weight: 20 };
}
};
export function LootTableBuilder({ pool, onChange }: LootTableBuilderProps) {
// Calculate total weight for probability display
const totalWeight = pool.reduce((sum, drop) => sum + drop.weight, 0);
const addDrop = useCallback(() => {
onChange([...pool, getDefaultDrop("BALANCE")]);
}, [pool, onChange]);
const removeDrop = useCallback((index: number) => {
onChange(pool.filter((_, i) => i !== index));
}, [pool, onChange]);
const updateDrop = useCallback((index: number, updates: Partial<LootDrop>) => {
onChange(pool.map((drop, i) => i === index ? { ...drop, ...updates } : drop));
}, [pool, onChange]);
const changeDropType = useCallback((index: number, newType: LootType) => {
onChange(pool.map((drop, i) => i === index ? getDefaultDrop(newType) : drop));
}, [pool, onChange]);
const getProbability = (weight: number): string => {
if (totalWeight === 0) return "0%";
return ((weight / totalWeight) * 100).toFixed(1) + "%";
};
if (pool.length === 0) {
return (
<div className="border border-dashed border-border/50 rounded-lg p-6 text-center">
<Package className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground mb-3">
No drops configured yet
</p>
<Button type="button" variant="outline" size="sm" onClick={addDrop}>
<Plus className="h-4 w-4 mr-2" />
Add Drop
</Button>
</div>
);
}
return (
<div className="space-y-3">
{/* Probability Summary Bar */}
{pool.length > 0 && (
<div className="flex h-6 rounded-lg overflow-hidden">
{pool.map((drop, index) => {
const lootType = LOOT_TYPES.find(t => t.value === drop.type);
const probability = totalWeight > 0 ? (drop.weight / totalWeight) * 100 : 0;
if (probability < 1) return null;
const colors: Record<LootType, string> = {
ITEM: "bg-purple-500",
BALANCE: "bg-amber-500",
XP: "bg-blue-500",
};
return (
<div
key={index}
className={cn(
"h-full transition-all duration-300 relative group cursor-pointer",
colors[drop.type]
)}
style={{ width: `${probability}%` }}
title={`${lootType?.label}: ${probability.toFixed(1)}%`}
>
{probability > 10 && (
<span className="absolute inset-0 flex items-center justify-center text-xs font-medium text-white">
{probability.toFixed(0)}%
</span>
)}
</div>
);
})}
</div>
)}
{/* Drop List */}
<div className="space-y-2">
{pool.map((drop, index) => {
const lootType = LOOT_TYPES.find(t => t.value === drop.type);
const Icon = lootType?.icon || Package;
return (
<Card key={index} className="bg-muted/20 border-border/30">
<CardContent className="p-4 space-y-3">
{/* Header Row */}
<div className="flex items-center gap-3">
<GripVertical className="h-4 w-4 text-muted-foreground/50 cursor-grab" />
{/* Type Selector */}
<Select
value={drop.type}
onValueChange={(value) => changeDropType(index, value as LootType)}
>
<SelectTrigger className="w-32 bg-background/50">
<div className="flex items-center gap-2">
<Icon className={cn("h-4 w-4", lootType?.color)} />
<SelectValue />
</div>
</SelectTrigger>
<SelectContent>
{LOOT_TYPES.map((type) => (
<SelectItem key={type.value} value={type.value}>
<div className="flex items-center gap-2">
<type.icon className={cn("h-4 w-4", type.color)} />
{type.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{/* Probability Badge */}
<div className="flex items-center gap-1 text-sm text-muted-foreground ml-auto">
<Percent className="h-3 w-3" />
{getProbability(drop.weight)}
</div>
{/* Delete Button */}
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeDrop(index)}
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
{/* Type-Specific Fields */}
<div className="grid grid-cols-2 gap-3">
{drop.type === "ITEM" && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Item ID</Label>
<Input
type="number"
value={drop.itemId || ""}
onChange={(e) => updateDrop(index, { itemId: parseInt(e.target.value) || 0 })}
placeholder="Item ID"
className="bg-background/50 h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Item Name (Optional)</Label>
<Input
value={drop.itemName || ""}
onChange={(e) => updateDrop(index, { itemName: e.target.value })}
placeholder="For reference only"
className="bg-background/50 h-8 text-sm"
/>
</div>
</>
)}
{(drop.type === "BALANCE" || drop.type === "XP") && (
<>
<div className="space-y-1.5">
<Label className="text-xs">Min Amount</Label>
<Input
type="number"
value={Array.isArray(drop.amount) ? drop.amount[0] : drop.amount || 0}
onChange={(e) => {
const min = parseInt(e.target.value) || 0;
const max = Array.isArray(drop.amount) ? drop.amount[1] : min;
updateDrop(index, { amount: [min, Math.max(min, max)] });
}}
className="bg-background/50 h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Max Amount</Label>
<Input
type="number"
value={Array.isArray(drop.amount) ? drop.amount[1] : drop.amount || 0}
onChange={(e) => {
const max = parseInt(e.target.value) || 0;
const min = Array.isArray(drop.amount) ? drop.amount[0] : max;
updateDrop(index, { amount: [Math.min(min, max), max] });
}}
className="bg-background/50 h-8 text-sm"
/>
</div>
</>
)}
</div>
{/* Weight Slider */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-xs">Weight</Label>
<span className="text-xs text-muted-foreground">{drop.weight}</span>
</div>
<Slider
value={[drop.weight]}
onValueChange={(values) => updateDrop(index, { weight: values[0] ?? drop.weight })}
min={1}
max={100}
step={1}
className="py-1"
/>
</div>
</CardContent>
</Card>
);
})}
</div>
{/* Add Button */}
<Button type="button" variant="outline" size="sm" onClick={addDrop}>
<Plus className="h-4 w-4 mr-2" />
Add Drop
</Button>
</div>
);
}
export default LootTableBuilder;

View File

@@ -0,0 +1,66 @@
/**
* RarityBadge Component
* Displays item rarity with appropriate colors and optional glow effect.
*/
import { cn } from "@/lib/utils";
export type Rarity = 'Common' | 'Uncommon' | 'Rare' | 'Epic' | 'Legendary';
interface RarityBadgeProps {
rarity: Rarity | string;
className?: string;
size?: 'sm' | 'md' | 'lg';
}
const rarityStyles: Record<Rarity, { bg: string; text: string; glow?: string }> = {
Common: {
bg: 'bg-zinc-600/30',
text: 'text-zinc-300',
},
Uncommon: {
bg: 'bg-emerald-600/30',
text: 'text-emerald-400',
},
Rare: {
bg: 'bg-blue-600/30',
text: 'text-blue-400',
},
Epic: {
bg: 'bg-purple-600/30',
text: 'text-purple-400',
},
Legendary: {
bg: 'bg-amber-600/30',
text: 'text-amber-400',
glow: 'shadow-[0_0_10px_rgba(251,191,36,0.4)]',
},
};
const sizeStyles = {
sm: 'text-xs px-1.5 py-0.5',
md: 'text-xs px-2 py-1',
lg: 'text-sm px-2.5 py-1',
};
export function RarityBadge({ rarity, className, size = 'md' }: RarityBadgeProps) {
const validRarity = (rarity in rarityStyles ? rarity : 'Common') as Rarity;
const styles = rarityStyles[validRarity];
return (
<span
className={cn(
'inline-flex items-center font-medium rounded-full',
styles.bg,
styles.text,
styles.glow,
sizeStyles[size],
className
)}
>
{validRarity}
</span>
);
}
export default RarityBadge;

View File

@@ -0,0 +1,26 @@
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className
)}
{...props}
>
<SliderPrimitive.Track className="relative h-2 w-full grow overflow-hidden rounded-full bg-secondary">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-5 w-5 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
))
Slider.displayName = SliderPrimitive.Root.displayName
export { Slider }

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 };
}

View File

@@ -1,18 +1,104 @@
import React from "react";
import { SectionHeader } from "../../components/section-header";
/**
* AdminItems Page
* Full item management page with filtering, table, and create/edit functionality.
*/
import { useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { SectionHeader } from "@/components/section-header";
import { ItemsTable } from "@/components/items-table";
import { ItemsFilter } from "@/components/items-filter";
import { ItemFormSheet } from "@/components/item-form-sheet";
import { useItems, useDeleteItem, type ItemWithUsage } from "@/hooks/use-items";
import { Plus } from "lucide-react";
export function AdminItems() {
return (
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-12">
<SectionHeader
badge="Item Management"
title="Items"
description="Create and manage items for the Aurora RPG."
/>
const { items, total, loading, filters, fetchItems, updateFilters, clearFilters } = useItems();
const { deleteItem } = useDeleteItem();
<div className="animate-in fade-in slide-up duration-700">
<p className="text-muted-foreground">Items management coming soon...</p>
const [isSheetOpen, setIsSheetOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ItemWithUsage | null>(null);
const [isRefreshing, setIsRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setIsRefreshing(true);
await fetchItems();
setIsRefreshing(false);
}, [fetchItems]);
const handleCreateClick = useCallback(() => {
setEditingItem(null);
setIsSheetOpen(true);
}, []);
const handleEditItem = useCallback((item: ItemWithUsage) => {
setEditingItem(item);
setIsSheetOpen(true);
}, []);
const handleDeleteItem = useCallback(async (item: ItemWithUsage) => {
const success = await deleteItem(item.id, item.name);
if (success) {
await fetchItems();
}
}, [deleteItem, fetchItems]);
const handleSheetClose = useCallback(() => {
setIsSheetOpen(false);
setEditingItem(null);
}, []);
const handleItemSaved = useCallback(async () => {
setIsSheetOpen(false);
setEditingItem(null);
await fetchItems();
}, [fetchItems]);
return (
<main className="pt-8 px-8 pb-12 max-w-7xl mx-auto space-y-8">
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<SectionHeader
badge="Item Management"
title="Items"
description="Create and manage game items, lootboxes, and consumables."
/>
<Button
onClick={handleCreateClick}
className="bg-primary hover:bg-primary/90 gap-2"
>
<Plus className="h-4 w-4" />
Create Item
</Button>
</div>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<ItemsFilter
filters={filters}
onFilterChange={updateFilters}
onClearFilters={clearFilters}
/>
</div>
<div className="animate-in fade-in slide-in-from-bottom-4 duration-700">
<ItemsTable
items={items}
total={total}
isLoading={loading}
isRefreshing={isRefreshing}
onRefresh={handleRefresh}
onEdit={handleEditItem}
onDelete={handleDeleteItem}
/>
</div>
<ItemFormSheet
open={isSheetOpen}
onOpenChange={(open: boolean) => {
if (!open) handleSheetClose();
}}
item={editingItem}
onSaved={handleItemSaved}
/>
</main>
);
}

View File

@@ -0,0 +1,435 @@
import { describe, test, expect, afterAll, beforeAll, mock } from "bun:test";
import type { WebServerInstance } from "./server";
import { createWebServer } from "./server";
/**
* Items API Integration Tests
*
* Tests the full CRUD functionality for the Items management API.
* Uses mocked database and service layers.
*/
// --- Mock Types ---
interface MockItem {
id: number;
name: string;
description: string | null;
rarity: string;
type: string;
price: bigint | null;
iconUrl: string;
imageUrl: string;
usageData: { consume: boolean; effects: any[] } | null;
}
// --- Mock Data ---
let mockItems: MockItem[] = [
{
id: 1,
name: "Health Potion",
description: "Restores health",
rarity: "Common",
type: "CONSUMABLE",
price: 100n,
iconUrl: "/assets/items/1.png",
imageUrl: "/assets/items/1.png",
usageData: { consume: true, effects: [] },
},
{
id: 2,
name: "Iron Sword",
description: "A basic sword",
rarity: "Uncommon",
type: "EQUIPMENT",
price: 500n,
iconUrl: "/assets/items/2.png",
imageUrl: "/assets/items/2.png",
usageData: null,
},
];
let mockIdCounter = 3;
// --- Mock Items Service ---
mock.module("@shared/modules/items/items.service", () => ({
itemsService: {
getAllItems: mock(async (filters: any = {}) => {
let filtered = [...mockItems];
if (filters.search) {
const search = filters.search.toLowerCase();
filtered = filtered.filter(
(item) =>
item.name.toLowerCase().includes(search) ||
(item.description?.toLowerCase().includes(search) ?? false)
);
}
if (filters.type) {
filtered = filtered.filter((item) => item.type === filters.type);
}
if (filters.rarity) {
filtered = filtered.filter((item) => item.rarity === filters.rarity);
}
return {
items: filtered,
total: filtered.length,
};
}),
getItemById: mock(async (id: number) => {
return mockItems.find((item) => item.id === id) ?? null;
}),
isNameTaken: mock(async (name: string, excludeId?: number) => {
return mockItems.some(
(item) =>
item.name.toLowerCase() === name.toLowerCase() &&
item.id !== excludeId
);
}),
createItem: mock(async (data: any) => {
const newItem: MockItem = {
id: mockIdCounter++,
name: data.name,
description: data.description ?? null,
rarity: data.rarity ?? "Common",
type: data.type,
price: data.price ?? null,
iconUrl: data.iconUrl,
imageUrl: data.imageUrl,
usageData: data.usageData ?? null,
};
mockItems.push(newItem);
return newItem;
}),
updateItem: mock(async (id: number, data: any) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index === -1) return null;
mockItems[index] = { ...mockItems[index], ...data };
return mockItems[index];
}),
deleteItem: mock(async (id: number) => {
const index = mockItems.findIndex((item) => item.id === id);
if (index === -1) return null;
const [deleted] = mockItems.splice(index, 1);
return deleted;
}),
},
}));
// --- Mock Utilities ---
mock.module("@shared/lib/utils", () => ({
jsonReplacer: (key: string, value: any) =>
typeof value === "bigint" ? value.toString() : value,
}));
// --- Mock Logger ---
mock.module("@shared/lib/logger", () => ({
logger: {
info: () => { },
warn: () => { },
error: () => { },
debug: () => { },
},
}));
describe("Items API", () => {
const port = 3002;
const hostname = "127.0.0.1";
const baseUrl = `http://${hostname}:${port}`;
let serverInstance: WebServerInstance | null = null;
beforeAll(async () => {
// Reset mock data before all tests
mockItems = [
{
id: 1,
name: "Health Potion",
description: "Restores health",
rarity: "Common",
type: "CONSUMABLE",
price: 100n,
iconUrl: "/assets/items/1.png",
imageUrl: "/assets/items/1.png",
usageData: { consume: true, effects: [] },
},
{
id: 2,
name: "Iron Sword",
description: "A basic sword",
rarity: "Uncommon",
type: "EQUIPMENT",
price: 500n,
iconUrl: "/assets/items/2.png",
imageUrl: "/assets/items/2.png",
usageData: null,
},
];
mockIdCounter = 3;
serverInstance = await createWebServer({ port, hostname });
});
afterAll(async () => {
if (serverInstance) {
await serverInstance.stop();
}
});
// ===========================================
// GET /api/items Tests
// ===========================================
describe("GET /api/items", () => {
test("should return all items", async () => {
const response = await fetch(`${baseUrl}/api/items`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items).toBeInstanceOf(Array);
expect(data.total).toBeGreaterThanOrEqual(0);
});
test("should filter items by search query", async () => {
const response = await fetch(`${baseUrl}/api/items?search=potion`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) =>
item.name.toLowerCase().includes("potion") ||
(item.description?.toLowerCase().includes("potion") ?? false)
)).toBe(true);
});
test("should filter items by type", async () => {
const response = await fetch(`${baseUrl}/api/items?type=CONSUMABLE`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) => item.type === "CONSUMABLE")).toBe(true);
});
test("should filter items by rarity", async () => {
const response = await fetch(`${baseUrl}/api/items?rarity=Common`);
expect(response.status).toBe(200);
const data = (await response.json()) as { items: MockItem[]; total: number };
expect(data.items.every((item) => item.rarity === "Common")).toBe(true);
});
});
// ===========================================
// GET /api/items/:id Tests
// ===========================================
describe("GET /api/items/:id", () => {
test("should return a single item by ID", async () => {
const response = await fetch(`${baseUrl}/api/items/1`);
expect(response.status).toBe(200);
const data = (await response.json()) as MockItem;
expect(data.id).toBe(1);
expect(data.name).toBe("Health Potion");
});
test("should return 404 for non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`);
expect(response.status).toBe(404);
const data = (await response.json()) as { error: string };
expect(data.error).toBe("Item not found");
});
});
// ===========================================
// POST /api/items Tests
// ===========================================
describe("POST /api/items", () => {
test("should create a new item", async () => {
const newItem = {
name: "Magic Staff",
description: "A powerful staff",
rarity: "Rare",
type: "EQUIPMENT",
price: "1000",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
};
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newItem),
});
expect(response.status).toBe(201);
const data = (await response.json()) as { success: boolean; item: MockItem };
expect(data.success).toBe(true);
expect(data.item.name).toBe("Magic Staff");
expect(data.item.id).toBeGreaterThan(0);
});
test("should reject item without required fields", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ description: "No name or type" }),
});
expect(response.status).toBe(400);
const data = (await response.json()) as { error: string };
expect(data.error).toContain("required");
});
test("should reject duplicate item name", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Health Potion", // Already exists
type: "CONSUMABLE",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
}),
});
expect(response.status).toBe(409);
const data = (await response.json()) as { error: string };
expect(data.error).toContain("already exists");
});
});
// ===========================================
// PUT /api/items/:id Tests
// ===========================================
describe("PUT /api/items/:id", () => {
test("should update an existing item", async () => {
const response = await fetch(`${baseUrl}/api/items/1`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
description: "Updated description",
price: "200",
}),
});
expect(response.status).toBe(200);
const data = (await response.json()) as { success: boolean; item: MockItem };
expect(data.success).toBe(true);
expect(data.item.description).toBe("Updated description");
});
test("should return 404 for updating non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "New Name" }),
});
expect(response.status).toBe(404);
});
test("should reject duplicate name when updating", async () => {
const response = await fetch(`${baseUrl}/api/items/2`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Health Potion", // ID 1 has this name
}),
});
expect(response.status).toBe(409);
});
});
// ===========================================
// DELETE /api/items/:id Tests
// ===========================================
describe("DELETE /api/items/:id", () => {
test("should delete an existing item", async () => {
// First, create an item to delete
const createResponse = await fetch(`${baseUrl}/api/items`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: "Item to Delete",
type: "MATERIAL",
iconUrl: "/assets/items/placeholder.png",
imageUrl: "/assets/items/placeholder.png",
}),
});
const { item } = (await createResponse.json()) as { item: MockItem };
// Now delete it
const deleteResponse = await fetch(`${baseUrl}/api/items/${item.id}`, {
method: "DELETE",
});
expect(deleteResponse.status).toBe(204);
// Verify it's gone
const getResponse = await fetch(`${baseUrl}/api/items/${item.id}`);
expect(getResponse.status).toBe(404);
});
test("should return 404 for deleting non-existent item", async () => {
const response = await fetch(`${baseUrl}/api/items/9999`, {
method: "DELETE",
});
expect(response.status).toBe(404);
});
});
// ===========================================
// Static Asset Serving Tests
// ===========================================
describe("Static Asset Serving (/assets/*)", () => {
test("should return 404 for non-existent asset", async () => {
const response = await fetch(`${baseUrl}/assets/items/nonexistent.png`);
expect(response.status).toBe(404);
});
test("should prevent path traversal attacks", async () => {
const response = await fetch(`${baseUrl}/assets/../../../etc/passwd`);
// Should either return 403 (Forbidden) or 404 (Not found after sanitization)
expect([403, 404]).toContain(response.status);
});
});
// ===========================================
// Validation Edge Cases
// ===========================================
describe("Validation Edge Cases", () => {
test("should handle empty search query gracefully", async () => {
const response = await fetch(`${baseUrl}/api/items?search=`);
expect(response.status).toBe(200);
});
test("should handle invalid pagination values", async () => {
const response = await fetch(`${baseUrl}/api/items?limit=abc&offset=xyz`);
// Should not crash, may use defaults
expect(response.status).toBe(200);
});
test("should handle missing content-type header", async () => {
const response = await fetch(`${baseUrl}/api/items`, {
method: "POST",
body: JSON.stringify({ name: "Test", type: "MATERIAL" }),
});
// May fail due to no content-type, but shouldn't crash
expect([200, 201, 400, 415]).toContain(response.status);
});
});
});

View File

@@ -358,6 +358,385 @@ export async function createWebServer(config: WebServerConfig = {}): Promise<Web
}
}
// =====================================
// Items Management API
// =====================================
// GET /api/items - List all items with filtering
if (url.pathname === "/api/items" && req.method === "GET") {
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const filters = {
search: url.searchParams.get("search") || undefined,
type: url.searchParams.get("type") || undefined,
rarity: url.searchParams.get("rarity") || undefined,
limit: url.searchParams.get("limit") ? parseInt(url.searchParams.get("limit")!) : 100,
offset: url.searchParams.get("offset") ? parseInt(url.searchParams.get("offset")!) : 0,
};
const result = await itemsService.getAllItems(filters);
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify(result, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error fetching items", error);
return Response.json(
{ error: "Failed to fetch items", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// POST /api/items - Create new item (JSON or multipart with image)
if (url.pathname === "/api/items" && req.method === "POST") {
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const contentType = req.headers.get("content-type") || "";
let itemData: any;
let imageFile: File | null = null;
if (contentType.includes("multipart/form-data")) {
// Handle multipart form with optional image
const formData = await req.formData();
const jsonData = formData.get("data");
imageFile = formData.get("image") as File | null;
if (typeof jsonData === "string") {
itemData = JSON.parse(jsonData);
} else {
return Response.json({ error: "Missing item data" }, { status: 400 });
}
} else {
// JSON-only request
itemData = await req.json();
}
// Validate required fields
if (!itemData.name || !itemData.type) {
return Response.json(
{ error: "Missing required fields: name and type are required" },
{ status: 400 }
);
}
// Check for duplicate name
if (await itemsService.isNameTaken(itemData.name)) {
return Response.json(
{ error: "An item with this name already exists" },
{ status: 409 }
);
}
// Set placeholder URLs if image will be uploaded
const placeholderUrl = "/assets/items/placeholder.png";
const createData = {
name: itemData.name,
description: itemData.description || null,
rarity: itemData.rarity || "Common",
type: itemData.type,
price: itemData.price ? BigInt(itemData.price) : null,
iconUrl: itemData.iconUrl || placeholderUrl,
imageUrl: itemData.imageUrl || placeholderUrl,
usageData: itemData.usageData || null,
};
// Create the item
const item = await itemsService.createItem(createData);
// If image was provided, save it and update the item
if (imageFile && item) {
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
const fileName = `${item.id}.png`;
const filePath = join(assetsDir, fileName);
// Validate file type (check magic bytes for PNG/JPEG/WebP/GIF)
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
if (!isPNG && !isJPEG && !isWebP && !isGIF) {
// Rollback: delete the created item
await itemsService.deleteItem(item.id);
return Response.json(
{ error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." },
{ status: 400 }
);
}
// Check file size (max 2MB)
if (buffer.byteLength > 2 * 1024 * 1024) {
await itemsService.deleteItem(item.id);
return Response.json(
{ error: "Image file too large. Maximum size is 2MB." },
{ status: 400 }
);
}
// Save the file
await Bun.write(filePath, buffer);
// Update item with actual asset URL
const assetUrl = `/assets/items/${fileName}`;
await itemsService.updateItem(item.id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
// Return item with updated URLs
const updatedItem = await itemsService.getItemById(item.id);
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
status: 201,
headers: { "Content-Type": "application/json" }
});
}
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item }, jsonReplacer), {
status: 201,
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error creating item", error);
return Response.json(
{ error: "Failed to create item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// GET /api/items/:id - Get single item
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "GET") {
const id = parseInt(url.pathname.split("/").pop()!);
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const item = await itemsService.getItemById(id);
if (!item) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify(item, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error fetching item", error);
return Response.json(
{ error: "Failed to fetch item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// PUT /api/items/:id - Update item
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "PUT") {
const id = parseInt(url.pathname.split("/").pop()!);
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const data = await req.json() as Record<string, any>;
// Check if item exists
const existing = await itemsService.getItemById(id);
if (!existing) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
// Check for duplicate name (if name is being changed)
if (data.name && data.name !== existing.name) {
if (await itemsService.isNameTaken(data.name, id)) {
return Response.json(
{ error: "An item with this name already exists" },
{ status: 409 }
);
}
}
// Build update data
const updateData: any = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description;
if (data.rarity !== undefined) updateData.rarity = data.rarity;
if (data.type !== undefined) updateData.type = data.type;
if (data.price !== undefined) updateData.price = data.price ? BigInt(data.price) : null;
if (data.iconUrl !== undefined) updateData.iconUrl = data.iconUrl;
if (data.imageUrl !== undefined) updateData.imageUrl = data.imageUrl;
if (data.usageData !== undefined) updateData.usageData = data.usageData;
const updatedItem = await itemsService.updateItem(id, updateData);
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error updating item", error);
return Response.json(
{ error: "Failed to update item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// DELETE /api/items/:id - Delete item
if (url.pathname.match(/^\/api\/items\/\d+$/) && req.method === "DELETE") {
const id = parseInt(url.pathname.split("/").pop()!);
try {
const { itemsService } = await import("@shared/modules/items/items.service");
const existing = await itemsService.getItemById(id);
if (!existing) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
// Delete the item
await itemsService.deleteItem(id);
// Try to delete associated asset file
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
const assetPath = join(assetsDir, `${id}.png`);
try {
const assetFile = Bun.file(assetPath);
if (await assetFile.exists()) {
await Bun.write(assetPath, ""); // Clear file
// Note: Bun doesn't have a direct delete, but we can use unlink via node:fs
const { unlink } = await import("node:fs/promises");
await unlink(assetPath);
}
} catch (e) {
// Non-critical: log but don't fail
logger.warn("web", `Could not delete asset file for item ${id}`, e);
}
return new Response(null, { status: 204 });
} catch (error) {
logger.error("web", "Error deleting item", error);
return Response.json(
{ error: "Failed to delete item", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// POST /api/items/:id/icon - Upload/update item icon
if (url.pathname.match(/^\/api\/items\/\d+\/icon$/) && req.method === "POST") {
const id = parseInt(url.pathname.split("/")[3] || "0");
try {
const { itemsService } = await import("@shared/modules/items/items.service");
// Check if item exists
const existing = await itemsService.getItemById(id);
if (!existing) {
return Response.json({ error: "Item not found" }, { status: 404 });
}
// Parse multipart form
const formData = await req.formData();
const imageFile = formData.get("image") as File | null;
if (!imageFile) {
return Response.json({ error: "No image file provided" }, { status: 400 });
}
// Validate file type
const buffer = await imageFile.arrayBuffer();
const bytes = new Uint8Array(buffer);
const isPNG = bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4E && bytes[3] === 0x47;
const isJPEG = bytes[0] === 0xFF && bytes[1] === 0xD8 && bytes[2] === 0xFF;
const isWebP = bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50;
const isGIF = bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46;
if (!isPNG && !isJPEG && !isWebP && !isGIF) {
return Response.json(
{ error: "Invalid image file. Only PNG, JPEG, WebP, and GIF are allowed." },
{ status: 400 }
);
}
// Check file size (max 2MB)
if (buffer.byteLength > 2 * 1024 * 1024) {
return Response.json(
{ error: "Image file too large. Maximum size is 2MB." },
{ status: 400 }
);
}
// Save the file
const assetsDir = resolve(currentDir, "../../bot/assets/graphics/items");
const fileName = `${id}.png`;
const filePath = join(assetsDir, fileName);
await Bun.write(filePath, buffer);
// Update item with new icon URL
const assetUrl = `/assets/items/${fileName}`;
const updatedItem = await itemsService.updateItem(id, {
iconUrl: assetUrl,
imageUrl: assetUrl,
});
const { jsonReplacer } = await import("@shared/lib/utils");
return new Response(JSON.stringify({ success: true, item: updatedItem }, jsonReplacer), {
headers: { "Content-Type": "application/json" }
});
} catch (error) {
logger.error("web", "Error uploading item icon", error);
return Response.json(
{ error: "Failed to upload icon", details: error instanceof Error ? error.message : String(error) },
{ status: 500 }
);
}
}
// =====================================
// Static Asset Serving (/assets/*)
// =====================================
if (url.pathname.startsWith("/assets/")) {
const assetsRoot = resolve(currentDir, "../../bot/assets/graphics");
const assetPath = url.pathname.replace("/assets/", "");
// Security: prevent path traversal
const safePath = join(assetsRoot, assetPath);
if (!safePath.startsWith(assetsRoot)) {
return new Response("Forbidden", { status: 403 });
}
const file = Bun.file(safePath);
if (await file.exists()) {
// Determine MIME type based on extension
const ext = safePath.split(".").pop()?.toLowerCase();
const mimeTypes: Record<string, string> = {
"png": "image/png",
"jpg": "image/jpeg",
"jpeg": "image/jpeg",
"webp": "image/webp",
"gif": "image/gif",
};
const contentType = mimeTypes[ext || ""] || "application/octet-stream";
return new Response(file, {
headers: {
"Content-Type": contentType,
"Cache-Control": "public, max-age=86400", // Cache for 24 hours
}
});
}
return new Response("Not found", { status: 404 });
}
// Static File Serving
let pathName = url.pathname;
if (pathName === "/") pathName = "/index.html";