feat(ui): complete Task 4 UI redesign — all sub-tasks 1-10
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 8m47s

- Implemented complete design system with 40+ CSS custom properties
- Created 9 UI primitives (GameCard, SectionHeader, StatRow, ManaBar, ElementBadge, ValueDisplay, ActionButton, SkillRow, TooltipInfo)
- Redesigned all tabs: Spire, Skills, Stats, Equipment, Crafting, Attunements, Golemancy, Spells, Loot, Achievements, Lab, Debug
- Added toast notification system (GameToast) with success/warning/error/info types
- Added confirmation dialogs for destructive actions
- Removed all dev artifacts and component name labels
- Added empty states to all tabs
- Replaced emoji icons with Lucide React icons
- Added enchantPower placeholder to StatsTab and EquipmentTab
- Mobile audit passed at 375px viewport
- Build passes with 0 errors, lint passes with 0 errors

Sub-tasks completed:
- ST1: Design System Implementation
- ST2: Global Layout & Header
- ST3: Left Panel (Mana Display & Action Area)
- ST4: Skills Tab
- ST5: Spire Tab & Spire Mode UI
- ST6: Stats Tab
- ST7: Equipment & Crafting Tabs
- ST8: Attunements Tab
- ST9: Remaining Tabs
- ST10: Toast System & Confirmation Dialogs

Documentation: 15+ files in docs/task4/
This commit is contained in:
Refactoring Agent
2026-04-28 11:38:45 +02:00
parent 3c29c1c834
commit 47c71e6f54
61 changed files with 6892 additions and 1842 deletions
+479 -355
View File
@@ -1,9 +1,9 @@
'use client';
import { useState } from 'react';
import {
EQUIPMENT_TYPES,
EQUIPMENT_SLOTS,
import { useState, useMemo } from 'react';
import {
EQUIPMENT_TYPES,
EQUIPMENT_SLOTS,
getEquipmentBySlot,
type EquipmentSlot,
type EquipmentType,
@@ -11,21 +11,40 @@ import {
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
import { fmt } from '@/lib/game/store';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { GameCard } from '@/components/ui/game-card';
import { SectionHeader } from '@/components/ui/section-header';
import { StatRow } from '@/components/ui/stat-row';
import { ActionButton } from '@/components/ui/action-button';
import { Badge } from '@/components/ui/badge';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Sword,
Shield,
ShieldOff,
Shirt,
Hand,
Footprints,
Gem,
X,
AlertCircle,
Info,
ChevronDown,
HardHat,
} from 'lucide-react';
import { useGameToast } from '@/components/game/GameToast';
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
export interface EquipmentTabProps {
@@ -44,410 +63,515 @@ const SLOT_NAMES: Record<EquipmentSlot, string> = {
accessory2: 'Accessory 2',
};
// Slot icons
const SLOT_ICONS: Record<EquipmentSlot, string> = {
mainHand: '⚔️',
offHand: '🛡️',
head: '🎩',
body: '👕',
hands: '🧤',
feet: '👢',
accessory1: '💍',
accessory2: '📿',
// Rarity color mappings using design system tokens
const RARITY_BORDER_COLORS: Record<string, string> = {
common: 'border-[var(--text-muted)]',
uncommon: 'border-[var(--color-success)]',
rare: 'border-[var(--mana-water)]',
epic: 'border-[var(--mana-stellar)]',
legendary: 'border-[var(--mana-light)]',
mythic: 'border-[var(--mana-dark)]',
};
// Rarity colors
const RARITY_COLORS: Record<string, string> = {
common: 'border-gray-500 bg-gray-800/30',
uncommon: 'border-green-500 bg-green-900/20',
rare: 'border-blue-500 bg-blue-900/20',
epic: 'border-purple-500 bg-purple-900/20',
legendary: 'border-amber-500 bg-amber-900/20',
mythic: 'border-red-500 bg-red-900/20',
const RARITY_BG_COLORS: Record<string, string> = {
common: 'bg-[var(--bg-sunken)]/30',
uncommon: 'bg-[var(--color-success)]/10',
rare: 'bg-[var(--mana-water)]/10',
epic: 'bg-[var(--mana-stellar)]/10',
legendary: 'bg-[var(--mana-light)]/10',
mythic: 'bg-[var(--mana-dark)]/10',
};
const RARITY_TEXT_COLORS: Record<string, string> = {
common: 'text-gray-300',
uncommon: 'text-green-400',
rare: 'text-blue-400',
epic: 'text-purple-400',
legendary: 'text-amber-400',
mythic: 'text-red-400',
common: 'text-[var(--text-secondary)]',
uncommon: 'text-[var(--color-success)]',
rare: 'text-[var(--mana-water)]',
epic: 'text-[var(--mana-stellar)]',
legendary: 'text-[var(--mana-light)]',
mythic: 'text-[var(--mana-dark)]',
};
// Slot icon mapping using Lucide icons
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
mainHand: Sword,
offHand: Shield,
head: HardHat,
body: Shirt,
hands: Hand,
feet: Footprints,
accessory1: Gem,
accessory2: Gem,
};
// Slot grouping for visual layout - requirement: visual slot layout
type SlotGroup = {
label: string;
slots: EquipmentSlot[];
};
const SLOT_GROUPS: SlotGroup[] = [
{ label: 'Weapon & Shield', slots: ['mainHand', 'offHand'] },
{ label: 'Armor', slots: ['head', 'body', 'hands', 'feet'] },
{ label: 'Accessories', slots: ['accessory1', 'accessory2'] },
];
export function EquipmentTab({ store }: EquipmentTabProps) {
const showToast = useGameToast();
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<{ instanceId: string; name: string } | null>(null);
// Get unequipped items
const equippedIds = new Set(Object.values(store.equippedInstances).filter(Boolean));
const unequippedItems = Object.values(store.equipmentInstances).filter(
(inst) => !equippedIds.has(inst.instanceId)
const equippedIds = useMemo(() =>
new Set(Object.values(store.equippedInstances).filter(Boolean)),
[store.equippedInstances]
);
const unequippedItems = useMemo(() =>
Object.values(store.equipmentInstances).filter(
(inst) => !equippedIds.has(inst.instanceId)
),
[store.equipmentInstances, equippedIds]
);
// Equip an item to a slot
const handleEquip = (instanceId: string, slot: EquipmentSlot) => {
const instance = store.equipmentInstances[instanceId];
store.equipItem(instanceId, slot);
setSelectedSlot(null);
showToast('success', 'Item Equipped', `${instance?.name || 'Item'} equipped to ${SLOT_NAMES[slot]}`);
};
// Unequip from a slot
const handleUnequip = (slot: EquipmentSlot) => {
const instanceId = store.equippedInstances[slot];
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
store.unequipItem(slot);
showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed from ${SLOT_NAMES[slot]}`);
};
// Get items that can be equipped in a slot
const getEquippableItems = (slot: EquipmentSlot): EquipmentInstance[] => {
const equipmentTypes = getEquipmentBySlot(slot);
const typeIds = new Set(equipmentTypes.map((t) => t.id));
return unequippedItems.filter((inst) => typeIds.has(inst.typeId));
};
// Check if a slot is blocked by a 2-handed weapon
// Check if a slot is blocked by a 2-handed weapon (task3 bug #6)
const isSlotBlocked = (slot: EquipmentSlot): boolean => {
if (slot === 'offHand' && store.equippedInstances.mainHand) {
const mainHandType = EQUIPMENT_TYPES[store.equippedInstances.mainHand];
const mainHandInstance = store.equipmentInstances[store.equippedInstances.mainHand];
if (!mainHandInstance) return false;
const mainHandType = EQUIPMENT_TYPES[mainHandInstance.typeId];
return mainHandType?.twoHanded === true;
}
return false;
};
// Get all items that can go in a slot (including accessories that can go in either accessory slot)
// Get all items that can go in a slot
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
// Don't show items for blocked slots
if (isSlotBlocked(slot)) return [];
if (slot === 'accessory1' || slot === 'accessory2') {
// Accessories can go in either slot
const accessoryTypes = EQUIPMENT_TYPES;
const accessoryTypeIds = Object.values(accessoryTypes)
const accessoryTypeIds = Object.values(EQUIPMENT_TYPES)
.filter((t) => t.category === 'accessory')
.map((t) => t.id);
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
}
// For offhand, don't show 2-handed weapons (they can only go in main hand)
if (slot === 'offHand') {
return getEquippableItems(slot).filter((inst) => {
const type = EQUIPMENT_TYPES[inst.typeId];
return !type?.twoHanded;
});
}
return getEquippableItems(slot);
};
return (
<div className="space-y-4">
{/* Equipment Slots */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipped Gear
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
{EQUIPMENT_SLOTS.map((slot) => {
const instanceId = store.equippedInstances[slot];
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
const equipmentType = instance ? EQUIPMENT_TYPES[instance.typeId] : null;
const blocked = isSlotBlocked(slot);
const slotElement = (
<div
className={`p-3 rounded border ${
blocked
? 'border-red-900/50 bg-red-950/20'
: instance
? RARITY_COLORS[instance.rarity]
: 'border-gray-700 bg-gray-800/30'
}`}
// Render a single equipment slot
const renderSlot = (slot: EquipmentSlot) => {
const instanceId = store.equippedInstances[slot];
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
const equipmentType = instance ? EQUIPMENT_TYPES[instance.typeId] : null;
const blocked = isSlotBlocked(slot);
const isEmpty = !instance;
const SlotIcon = SLOT_ICONS[slot];
const slotContent = (
<GameCard
variant={blocked ? 'danger' : instance ? 'default' : 'sunken'}
className={`relative transition-all duration-200
${isEmpty && !blocked ? 'border-dashed' : ''}
${blocked ? 'opacity-60 cursor-not-allowed' : 'hover:border-[var(--border-default)]'}
`}
role="button"
aria-label={`${SLOT_NAMES[slot]} slot${blocked ? ' (blocked by 2-handed weapon)' : ''}${instance ? `: ${instance.name}` : ' (empty)'}`}
tabIndex={blocked ? -1 : 0}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<SlotIcon
size={16}
className={blocked ? 'text-[var(--text-disabled)]' : 'text-[var(--text-secondary)]'}
/>
<span
className={`text-sm font-semibold
${blocked ? 'text-[var(--text-disabled)]' : 'text-[var(--text-primary)]'}
`}
>
{SLOT_NAMES[slot]}
</span>
{blocked && (
<Badge
variant="outline"
className="text-xs border-[var(--mana-dark)] text-[var(--mana-dark)] ml-2"
>
<AlertCircle size={12} className="mr-1" />
Occupied 2H Weapon
</Badge>
)}
</div>
{instance && !blocked && (
<ActionButton
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
onClick={(e) => {
e.stopPropagation();
handleUnequip(slot);
}}
aria-label={`Unequip ${instance.name}`}
>
<X size={14} />
</ActionButton>
)}
</div>
{instance ? (
<div className="space-y-1">
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity] || 'text-[var(--text-primary)]'}`}>
{instance.name}
{equipmentType?.twoHanded && (
<Badge
variant="outline"
className="ml-2 text-xs border-[var(--mana-light)] text-[var(--mana-light)]"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span>{SLOT_ICONS[slot]}</span>
<span className={`text-sm font-semibold ${
blocked ? 'text-red-400' : 'text-gray-300'
}`}>
{SLOT_NAMES[slot]}
</span>
{blocked && (
<Badge variant="outline" className="text-xs text-red-400 border-red-400">
Blocked
</Badge>
2-Handed
</Badge>
)}
</div>
<div className="text-xs text-[var(--text-secondary)]">
Enchantments: {instance.enchantments.length}/{instance.totalCapacity}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<TooltipProvider key={i}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs cursor-help border-[var(--border-default)] text-[var(--text-secondary)]"
>
{effect?.name || ench.effectId}
{ench.stacks > 1 && ` x${ench.stacks}`}
</Badge>
</TooltipTrigger>
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
<p>{effect?.description || 'Unknown effect'}</p>
<p className="text-[var(--text-muted)] text-xs">
Category: {effect?.category || 'unknown'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div>
)}
</div>
) : blocked ? (
<div className="text-sm text-[var(--text-disabled)] italic">
<AlertCircle size={14} className="inline mr-1" />
Blocked by 2-handed weapon
</div>
) : (
<div className="text-sm text-[var(--text-muted)] italic text-center py-2">
{SLOT_NAMES[slot]}
</div>
)}
</GameCard>
);
if (blocked) {
return (
<TooltipProvider key={slot}>
<Tooltip>
<TooltipTrigger asChild>
{slotContent}
</TooltipTrigger>
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
<p>The offhand slot is blocked because a 2-handed weapon is equipped in the main hand.</p>
<p className="text-[var(--text-muted)] text-xs mt-1">Unequip the 2-handed weapon to use this slot.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <div key={slot}>{slotContent}</div>;
};
return (
<div className="space-y-4 max-w-full overflow-x-hidden">
{/* Equipment Slots - Requirement: Visual slot layout */}
<GameCard variant="default">
<SectionHeader
title="Equipped Gear"
action={
<span className="text-xs text-[var(--text-muted)]">
{Object.values(store.equippedInstances).filter(Boolean).length} / {EQUIPMENT_SLOTS.length} slots filled
</span>
}
/>
<div className="space-y-6">
{/* Render slot groups */}
{SLOT_GROUPS.map((group) => (
<div key={group.label}>
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-2">
{group.label}
</h4>
<div className={`grid gap-3
/* Mobile: 2 columns for all groups - requirement: mobile layout */
grid-cols-2
/* Tablet and up */
${group.slots.includes('mainHand' as EquipmentSlot) ? 'sm:grid-cols-2' : 'sm:grid-cols-2 lg:grid-cols-4'}
`}>
{group.slots.map((slot) => renderSlot(slot))}
</div>
</div>
))}
</div>
</GameCard>
{/* Inventory */}
<GameCard variant="default">
<SectionHeader
title={`Equipment Inventory (${unequippedItems.length} items)`}
/>
{unequippedItems.length === 0 ? (
<div className="text-[var(--text-muted)] text-sm text-center py-4" role="status">
No unequipped items. Craft new gear in the Crafting tab.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
{unequippedItems.map((instance) => {
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
const validSlots = equipmentType
? (equipmentType.category === 'accessory'
? ['accessory1', 'accessory2'] as EquipmentSlot[]
: [equipmentType.slot])
: [];
return (
<GameCard
key={instance.instanceId}
variant="default"
className={`${RARITY_BORDER_COLORS[instance.rarity] || 'border-[var(--border-default)]'} ${RARITY_BG_COLORS[instance.rarity] || ''}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity] || 'text-[var(--text-primary)]'}`}>
{instance.name}
</div>
<div className="text-xs text-[var(--text-muted)]">
{equipmentType?.description}
</div>
</div>
<Badge variant="outline" className="text-xs border-[var(--border-default)] text-[var(--text-secondary)]">
{equipmentType?.category || 'unknown'}
</Badge>
</div>
<div className="text-xs text-[var(--text-muted)] space-y-1 mb-2">
<div>
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
{instance.quality < 100 && (
<span className="text-[var(--mana-light)] ml-1">
(Quality: {instance.quality}%)
</span>
)}
</div>
{instance && !blocked && (
<Button
size="sm"
variant="ghost"
className="h-6 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => handleUnequip(slot)}
>
</Button>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<Badge
key={i}
variant="outline"
className="text-xs border-[var(--border-default)] text-[var(--text-secondary)]"
>
{effect?.name || ench.effectId}
</Badge>
);
})}
</div>
)}
</div>
{instance ? (
<div className="space-y-1">
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
{instance.name}
{equipmentType?.twoHanded && (
<Badge variant="outline" className="ml-2 text-xs text-amber-400 border-amber-400">
2-Handed
</Badge>
)}
</div>
<div className="text-xs text-gray-400">
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<TooltipProvider key={i}>
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="text-xs cursor-help"
>
{effect?.name || ench.effectId}
{ench.stacks > 1 && ` x${ench.stacks}`}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{effect?.description || 'Unknown effect'}</p>
<p className="text-gray-400 text-xs">
Category: {effect?.category || 'unknown'}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
})}
</div>
)}
</div>
) : blocked ? (
<div className="text-sm text-red-400 italic">
Blocked by 2-handed weapon
</div>
) : (
<div className="text-sm text-gray-500 italic">
Empty
{validSlots.length > 0 && (
<div className="flex items-center gap-2">
<Select
onValueChange={(value) =>
handleEquip(instance.instanceId, value as EquipmentSlot)
}
>
<SelectTrigger className="h-8 text-xs bg-[var(--bg-sunken)] border-[var(--border-default)]">
<SelectValue placeholder="Equip to..." />
</SelectTrigger>
<SelectContent className="bg-[var(--bg-elevated)] border-[var(--border-default)]">
{validSlots.map((slot) => (
<SelectItem
key={slot}
value={slot}
className="text-xs text-[var(--text-primary)] focus:bg-[var(--bg-sunken)]"
>
<div className="flex items-center gap-2">
{(() => {
const Icon = SLOT_ICONS[slot];
return <Icon size={14} />;
})()}
{SLOT_NAMES[slot]}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<ActionButton
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
onClick={() => setDeleteConfirm({ instanceId: instance.instanceId, name: instance.name })}
aria-label={`Delete ${instance.name}`}
>
<X size={14} />
</ActionButton>
</TooltipTrigger>
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
<p>Delete this item</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
</GameCard>
);
// Wrap blocked slots with a tooltip
if (blocked) {
return (
<TooltipProvider key={slot}>
<Tooltip>
<TooltipTrigger asChild>
{slotElement}
</TooltipTrigger>
<TooltipContent>
<p>The offhand slot is blocked because a 2-handed weapon is equipped in the main hand.</p>
<p className="text-gray-400 text-xs mt-1">Unequip the 2-handed weapon to use this slot.</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
return <div key={slot}>{slotElement}</div>;
})}
</div>
</CardContent>
</Card>
{/* Inventory */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipment Inventory ({unequippedItems.length} items)
</CardTitle>
</CardHeader>
<CardContent>
{unequippedItems.length === 0 ? (
<div className="text-gray-500 text-sm text-center py-4">
No unequipped items. Craft new gear in the Crafting tab.
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
{unequippedItems.map((instance) => {
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
const validSlots = equipmentType
? (equipmentType.category === 'accessory'
? ['accessory1', 'accessory2'] as EquipmentSlot[]
: [equipmentType.slot])
: [];
return (
<div
key={instance.instanceId}
className={`p-3 rounded border ${RARITY_COLORS[instance.rarity]}`}
>
<div className="flex items-start justify-between mb-2">
<div>
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity]}`}>
{instance.name}
</div>
<div className="text-xs text-gray-400">
{equipmentType?.description}
</div>
</div>
<Badge variant="outline" className="text-xs">
{equipmentType?.category || 'unknown'}
</Badge>
</div>
<div className="text-xs text-gray-400 space-y-1 mb-2">
<div>
Capacity: {instance.usedCapacity}/{instance.totalCapacity}
{instance.quality < 100 && (
<span className="text-yellow-500 ml-1">
(Quality: {instance.quality}%)
</span>
)}
</div>
{instance.enchantments.length > 0 && (
<div className="flex flex-wrap gap-1">
{instance.enchantments.map((ench, i) => {
const effect = ENCHANTMENT_EFFECTS[ench.effectId];
return (
<Badge
key={i}
variant="outline"
className="text-xs"
>
{effect?.name || ench.effectId}
</Badge>
);
})}
</div>
)}
</div>
{validSlots.length > 0 && (
<div className="flex items-center gap-2">
<Select
onValueChange={(value) =>
handleEquip(instance.instanceId, value as EquipmentSlot)
}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Equip to..." />
</SelectTrigger>
<SelectContent>
{validSlots.map((slot) => (
<SelectItem
key={slot}
value={slot}
className="text-xs"
>
{SLOT_ICONS[slot]} {SLOT_NAMES[slot]}
</SelectItem>
))}
</SelectContent>
</Select>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs text-red-400 hover:text-red-300 hover:bg-red-900/20"
onClick={() => store.deleteEquipmentInstance(instance.instanceId)}
>
🗑
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Delete this item</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
)}
</GameCard>
{/* Equipment Stats Summary */}
<Card className="bg-gray-900/80 border-gray-700">
<CardHeader className="pb-2">
<CardTitle className="text-amber-400 game-panel-title text-xs">
Equipment Stats Summary
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-amber-400 game-mono">
{Object.values(store.equipmentInstances).length}
</div>
<div className="text-xs text-gray-400">Total Items</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-green-400 game-mono">
{equippedIds.size}
</div>
<div className="text-xs text-gray-400">Equipped</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-blue-400 game-mono">
{unequippedItems.length}
</div>
<div className="text-xs text-gray-400">In Inventory</div>
</div>
<div className="p-3 bg-gray-800/50 rounded text-center">
<div className="text-2xl font-bold text-purple-400 game-mono">
{Object.values(store.equipmentInstances).reduce(
(sum, inst) => sum + inst.enchantments.length,
0
)}
</div>
<div className="text-xs text-gray-400">Total Enchantments</div>
<GameCard variant="default">
<SectionHeader title="Equipment Stats Summary" />
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--mana-light)] font-[var(--font-mono)]">
{Object.values(store.equipmentInstances).length}
</div>
<div className="text-xs text-[var(--text-muted)]">Total Items</div>
</div>
{/* Active Effects from Equipment */}
<div className="mt-4">
<div className="text-sm text-gray-400 mb-2">Active Effects from Equipment:</div>
<div className="flex flex-wrap gap-2">
{(() => {
const effects = store.getEquipmentEffects();
const effectEntries = Object.entries(effects).filter(([, v]) => v > 0);
if (effectEntries.length === 0) {
return <span className="text-gray-500 text-sm">No active effects</span>;
}
return effectEntries.map(([stat, value]) => (
<Badge key={stat} variant="outline" className="text-xs">
{stat}: +{fmt(value)}
</Badge>
));
})()}
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--color-success)] font-[var(--font-mono)]">
{equippedIds.size}
</div>
<div className="text-xs text-[var(--text-muted)]">Equipped</div>
</div>
</CardContent>
</Card>
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--mana-water)] font-[var(--font-mono)]">
{unequippedItems.length}
</div>
<div className="text-xs text-[var(--text-muted)]">In Inventory</div>
</div>
<div className="p-3 bg-[var(--bg-sunken)]/50 rounded text-center">
<div className="text-2xl font-bold text-[var(--mana-stellar)] font-[var(--font-mono)]">
{Object.values(store.equipmentInstances).reduce(
(sum, inst) => sum + inst.enchantments.length,
0
)}
</div>
<div className="text-xs text-[var(--text-muted)]">Total Enchantments</div>
</div>
</div>
{/* Enchantment Power (placeholder for Task 5) */}
<GameCard className="mt-4">
<div className="pb-2">
<h3 className="text-sm font-semibold text-[var(--text-primary)] flex items-center gap-2">
Enchantment Power
</h3>
</div>
<div>
<StatRow
label="Enchantment Power:"
value="1.0×"
highlight="info"
/>
<p className="text-xs text-[var(--text-muted)] mt-2">
Increases the power of all enchantments. Will be wired from Task 5 implementation.
</p>
</div>
</GameCard>
{/* Active Effects from Equipment */}
<div className="mt-4">
<div className="text-sm text-[var(--text-muted)] mb-2">Active Effects from Equipment:</div>
<div className="flex flex-wrap gap-2">
{(() => {
const effects = store.getEquipmentEffects();
const effectEntries = Object.entries(effects).filter(([, v]) => v > 0);
if (effectEntries.length === 0) {
return <span className="text-[var(--text-muted)] text-sm">No active effects</span>;
}
return effectEntries.map(([stat, value]) => (
<Badge key={stat} variant="outline" className="text-xs border-[var(--border-default)] text-[var(--text-secondary)]">
{stat}: +{fmt(value)}
</Badge>
));
})()}
</div>
</div>
</GameCard>
{/* Delete Confirmation Dialog */}
{deleteConfirm && (
<ConfirmDialog
open={!!deleteConfirm}
onOpenChange={() => setDeleteConfirm(null)}
title="Discard Item?"
description={`Discard ${deleteConfirm.name}? This cannot be undone.`}
variant="danger"
confirmText="Discard"
onConfirm={() => {
store.deleteEquipmentInstance(deleteConfirm.instanceId);
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
setDeleteConfirm(null);
}}
/>
)}
</div>
);
}
EquipmentTab.displayName = "EquipmentTab";
EquipmentTab.displayName = 'EquipmentTab';