forked from syntaxbullet/aurorabot
feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
This commit is contained in:
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
397
web/src/components/effect-editor.tsx
Normal file
397
web/src/components/effect-editor.tsx
Normal 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;
|
||||
230
web/src/components/image-uploader.tsx
Normal file
230
web/src/components/image-uploader.tsx
Normal 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;
|
||||
70
web/src/components/item-form-sheet.tsx
Normal file
70
web/src/components/item-form-sheet.tsx
Normal 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;
|
||||
381
web/src/components/item-form.tsx
Normal file
381
web/src/components/item-form.tsx
Normal 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;
|
||||
140
web/src/components/items-filter.tsx
Normal file
140
web/src/components/items-filter.tsx
Normal 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;
|
||||
342
web/src/components/items-table.tsx
Normal file
342
web/src/components/items-table.tsx
Normal 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;
|
||||
275
web/src/components/loot-table-builder.tsx
Normal file
275
web/src/components/loot-table-builder.tsx
Normal 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;
|
||||
66
web/src/components/rarity-badge.tsx
Normal file
66
web/src/components/rarity-badge.tsx
Normal 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;
|
||||
26
web/src/components/ui/slider.tsx
Normal file
26
web/src/components/ui/slider.tsx
Normal 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
306
web/src/hooks/use-items.ts
Normal 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 };
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
435
web/src/server.items.test.ts
Normal file
435
web/src/server.items.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user