forked from syntaxbullet/aurorabot
feat: implement comprehensive item management system with admin UI, API, and asset handling utilities.
This commit is contained in:
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;
|
||||
Reference in New Issue
Block a user