refactor: extract components from EquipmentTab.tsx to reduce below 400 lines
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m12s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m12s
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
|
interface EnchantmentsPanelProps {
|
||||||
|
enchantments: Array<{ effectId: string; stacks: number }>;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EnchantmentsPanel({
|
||||||
|
enchantments,
|
||||||
|
compact = false,
|
||||||
|
}: EnchantmentsPanelProps) {
|
||||||
|
if (enchantments.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap gap-1 ${compact ? 'mt-1' : ''}`}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||||
|
import type { GameStore } from '@/lib/game/types';
|
||||||
|
import { ActionButton } from '@/components/ui/action-button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EquipmentControlsProps {
|
||||||
|
store: GameStore;
|
||||||
|
onUnequip: (slot: EquipmentSlot) => void;
|
||||||
|
onDelete: (instanceId: string, name: string) => void;
|
||||||
|
selectedSlot: EquipmentSlot | null;
|
||||||
|
isSlotBlocked: (slot: EquipmentSlot) => boolean;
|
||||||
|
isEquipped: (instanceId: string) => boolean;
|
||||||
|
getEquippableSlots: (typeId: string) => EquipmentSlot[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentControls({
|
||||||
|
store,
|
||||||
|
onUnequip,
|
||||||
|
onDelete,
|
||||||
|
selectedSlot,
|
||||||
|
isSlotBlocked,
|
||||||
|
isEquipped,
|
||||||
|
getEquippableSlots,
|
||||||
|
}: EquipmentControlsProps) {
|
||||||
|
const SLOT_NAMES = {
|
||||||
|
mainHand: 'Main Hand',
|
||||||
|
offHand: 'Off Hand',
|
||||||
|
head: 'Head',
|
||||||
|
body: 'Body',
|
||||||
|
hands: 'Hands',
|
||||||
|
feet: 'Feet',
|
||||||
|
accessory1: 'Accessory 1',
|
||||||
|
accessory2: 'Accessory 2',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderUnequipButton: (slot: EquipmentSlot, instanceName: string) => (
|
||||||
|
<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();
|
||||||
|
onUnequip(slot);
|
||||||
|
}}
|
||||||
|
aria-label={`Unequip ${instanceName}`}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</ActionButton>
|
||||||
|
),
|
||||||
|
|
||||||
|
renderDeleteButton: (instanceId: string, name: string) => (
|
||||||
|
<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={() => onDelete(instanceId, name)}
|
||||||
|
aria-label={`Delete ${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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||||
|
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||||
|
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
|
||||||
|
import { GameCard } from '@/components/ui/game-card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { EnchantmentsPanel } from './EnchantmentsPanel';
|
||||||
|
|
||||||
|
interface EquipmentInventoryProps {
|
||||||
|
store: GameStore;
|
||||||
|
unequippedItems: EquipmentInstance[];
|
||||||
|
onEquip: (instanceId: string, slot: EquipmentSlot) => void;
|
||||||
|
onDelete: (instanceId: string, name: string) => void;
|
||||||
|
getEquippableSlots: (typeId: string) => EquipmentSlot[];
|
||||||
|
SLOT_NAMES: Record<EquipmentSlot, string>;
|
||||||
|
SLOT_ICONS: Record<EquipmentSlot, React.ElementType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentInventory({
|
||||||
|
store,
|
||||||
|
unequippedItems,
|
||||||
|
onEquip,
|
||||||
|
onDelete,
|
||||||
|
getEquippableSlots,
|
||||||
|
SLOT_NAMES,
|
||||||
|
SLOT_ICONS,
|
||||||
|
}: EquipmentInventoryProps) {
|
||||||
|
return (
|
||||||
|
<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 = getEquippableSlots(instance.typeId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GameCard
|
||||||
|
key={instance.instanceId}
|
||||||
|
variant="default"
|
||||||
|
className={`${getRarityBorderColor(instance.rarity)} ${getRarityBgColor(instance.rarity)}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<div className={`font-semibold text-sm ${getRarityTextColor(instance.rarity)}`}>
|
||||||
|
{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.enchantments.length > 0 && (
|
||||||
|
<EnchantmentsPanel enchantments={instance.enchantments} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validSlots.length > 0 && (
|
||||||
|
<EquipControls
|
||||||
|
instance={instance}
|
||||||
|
validSlots={validSlots}
|
||||||
|
onEquip={onEquip}
|
||||||
|
onDelete={onDelete}
|
||||||
|
SLOT_NAMES={SLOT_NAMES}
|
||||||
|
SLOT_ICONS={SLOT_ICONS}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</GameCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityBorderColor(rarity: string) {
|
||||||
|
const 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)]',
|
||||||
|
};
|
||||||
|
return colors[rarity] || 'border-[var(--border-default)]';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityBgColor(rarity: string) {
|
||||||
|
const 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',
|
||||||
|
};
|
||||||
|
return colors[rarity] || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRarityTextColor(rarity: string) {
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
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)]',
|
||||||
|
};
|
||||||
|
return colors[rarity] || 'text-[var(--text-primary)]';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipControlsProps {
|
||||||
|
instance: EquipmentInstance;
|
||||||
|
validSlots: EquipmentSlot[];
|
||||||
|
onEquip: (instanceId: string, slot: EquipmentSlot) => void;
|
||||||
|
onDelete: (instanceId: string, name: string) => void;
|
||||||
|
SLOT_NAMES: Record<EquipmentSlot, string>;
|
||||||
|
SLOT_ICONS: Record<EquipmentSlot, React.ElementType>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipControls({
|
||||||
|
instance,
|
||||||
|
validSlots,
|
||||||
|
onEquip,
|
||||||
|
onDelete,
|
||||||
|
SLOT_NAMES,
|
||||||
|
SLOT_ICONS,
|
||||||
|
}: EquipControlsProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
onValueChange={(value) => onEquip(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>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="h-8 w-8 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20 rounded flex items-center justify-center"
|
||||||
|
onClick={() => onDelete(instance.instanceId, instance.name)}
|
||||||
|
aria-label={`Delete ${instance.name}`}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { EquipmentSlot } from '@/lib/game/data/equipment';
|
||||||
|
import { SLOT_NAMES } from './EquipmentTab';
|
||||||
|
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
|
||||||
|
import { GameCard } from '@/components/ui/game-card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { AlertCircle, Sword, Shield, HardHat, Shirt, Hand, Footprints, Gem } from 'lucide-react';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { RARITY_BORDER_COLORS, RARITY_BG_COLORS, RARITY_TEXT_COLORS } from './EquipmentTab';
|
||||||
|
import { ENCHANTMENT_EFFECTS } from '@/lib/game/data/enchantment-effects';
|
||||||
|
import type { EquipmentType } from '@/lib/game/data/equipment';
|
||||||
|
|
||||||
|
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
|
||||||
|
mainHand: Sword,
|
||||||
|
offHand: Shield,
|
||||||
|
head: HardHat,
|
||||||
|
body: Shirt,
|
||||||
|
hands: Hand,
|
||||||
|
feet: Footprints,
|
||||||
|
accessory1: Gem,
|
||||||
|
accessory2: Gem,
|
||||||
|
};
|
||||||
|
|
||||||
|
interface EquipmentSlotGridProps {
|
||||||
|
store: GameStore;
|
||||||
|
selectedSlot: EquipmentSlot | null;
|
||||||
|
onSlotClick: (slot: EquipmentSlot) => void;
|
||||||
|
onUnequip: (slot: EquipmentSlot) => void;
|
||||||
|
isSlotBlocked: (slot: EquipmentSlot) => boolean;
|
||||||
|
SLOT_GROUPS: Array<{ label: string; slots: EquipmentSlot[] }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EquipmentSlotGrid({
|
||||||
|
store,
|
||||||
|
selectedSlot,
|
||||||
|
onSlotClick,
|
||||||
|
onUnequip,
|
||||||
|
isSlotBlocked,
|
||||||
|
SLOT_GROUPS,
|
||||||
|
}: EquipmentSlotGridProps) {
|
||||||
|
const renderSlot = (slot: EquipmentSlot) => {
|
||||||
|
const instanceId = store.equippedInstances[slot];
|
||||||
|
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
|
||||||
|
const equipmentType = instance ? (store as any).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}
|
||||||
|
onClick={() => !blocked && onSlotClick(slot)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (!blocked && (e.key === 'Enter' || e.key === ' ')) {
|
||||||
|
onSlotClick(slot);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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 && (
|
||||||
|
<button
|
||||||
|
className="h-6 w-6 p-0 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20 rounded flex items-center justify-center"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onUnequip(slot);
|
||||||
|
}}
|
||||||
|
aria-label={`Unequip ${instance.name}`}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18" />
|
||||||
|
<line x1="6" y1="6" x2="18" y2="18" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{instance ? (
|
||||||
|
<EquipmentItemDisplay
|
||||||
|
instance={instance}
|
||||||
|
equipmentType={equipmentType}
|
||||||
|
isTwoHanded={equipmentType?.twoHanded || false}
|
||||||
|
isCompact={true}
|
||||||
|
/>
|
||||||
|
) : 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-6">
|
||||||
|
{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
|
||||||
|
grid-cols-2
|
||||||
|
${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EquipmentItemDisplayProps {
|
||||||
|
instance: EquipmentInstance;
|
||||||
|
equipmentType: EquipmentType | undefined;
|
||||||
|
isTwoHanded: boolean;
|
||||||
|
isCompact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EquipmentItemDisplay({
|
||||||
|
instance,
|
||||||
|
equipmentType,
|
||||||
|
isTwoHanded,
|
||||||
|
isCompact = false,
|
||||||
|
}: EquipmentItemDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className={`font-semibold text-sm ${RARITY_TEXT_COLORS[instance.rarity] || 'text-[var(--text-primary)]'}`}>
|
||||||
|
{instance.name}
|
||||||
|
{isTwoHanded && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="ml-2 text-xs border-[var(--mana-light)] text-[var(--mana-light)]"
|
||||||
|
>
|
||||||
|
2-Handed
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-[var(--text-secondary)]">
|
||||||
|
Enchantments: {instance.enchantments.length}/{instance.totalCapacity}
|
||||||
|
</div>
|
||||||
|
{instance.enchantments.length > 0 && (
|
||||||
|
<EnchantmentsDisplay enchantments={instance.enchantments} compact={isCompact} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EnchantmentsDisplayProps {
|
||||||
|
enchantments: Array<{ effectId: string; stacks: number }>;
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EnchantmentsDisplay({ enchantments, compact = false }: EnchantmentsDisplayProps) {
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-wrap gap-1 ${compact ? 'mt-1' : ''}`}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -15,42 +15,13 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { GameCard } from '@/components/ui/game-card';
|
import { GameCard } from '@/components/ui/game-card';
|
||||||
import { SectionHeader } from '@/components/ui/section-header';
|
import { SectionHeader } from '@/components/ui/section-header';
|
||||||
import { StatRow } from '@/components/ui/stat-row';
|
import { StatRow } from '@/components/ui/stat-row';
|
||||||
import { ActionButton } from '@/components/ui/action-button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
|
||||||
Tooltip,
|
import { EquipmentSlotGrid } from './EquipmentSlotGrid';
|
||||||
TooltipContent,
|
import { EquipmentInventory } from './EquipmentInventory';
|
||||||
TooltipProvider,
|
import { EnchantmentsPanel } from './EnchantmentsPanel';
|
||||||
TooltipTrigger,
|
|
||||||
} from '@/components/ui/tooltip';
|
|
||||||
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 { useGameToast } from '@/components/game/GameToast';
|
||||||
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
|
import { ConfirmDialog } from '@/components/game/ConfirmDialog';
|
||||||
import type { GameStore, EquipmentInstance } from '@/lib/game/types';
|
|
||||||
|
|
||||||
export interface EquipmentTabProps {
|
|
||||||
store: GameStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Slot display names
|
// Slot display names
|
||||||
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||||
@@ -65,7 +36,7 @@ const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Rarity color mappings using design system tokens
|
// Rarity color mappings using design system tokens
|
||||||
const RARITY_BORDER_COLORS: Record<string, string> = {
|
export const RARITY_BORDER_COLORS: Record<string, string> = {
|
||||||
common: 'border-[var(--text-muted)]',
|
common: 'border-[var(--text-muted)]',
|
||||||
uncommon: 'border-[var(--color-success)]',
|
uncommon: 'border-[var(--color-success)]',
|
||||||
rare: 'border-[var(--mana-water)]',
|
rare: 'border-[var(--mana-water)]',
|
||||||
@@ -74,7 +45,7 @@ const RARITY_BORDER_COLORS: Record<string, string> = {
|
|||||||
mythic: 'border-[var(--mana-dark)]',
|
mythic: 'border-[var(--mana-dark)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RARITY_BG_COLORS: Record<string, string> = {
|
export const RARITY_BG_COLORS: Record<string, string> = {
|
||||||
common: 'bg-[var(--bg-sunken)]/30',
|
common: 'bg-[var(--bg-sunken)]/30',
|
||||||
uncommon: 'bg-[var(--color-success)]/10',
|
uncommon: 'bg-[var(--color-success)]/10',
|
||||||
rare: 'bg-[var(--mana-water)]/10',
|
rare: 'bg-[var(--mana-water)]/10',
|
||||||
@@ -83,7 +54,7 @@ const RARITY_BG_COLORS: Record<string, string> = {
|
|||||||
mythic: 'bg-[var(--mana-dark)]/10',
|
mythic: 'bg-[var(--mana-dark)]/10',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RARITY_TEXT_COLORS: Record<string, string> = {
|
export const RARITY_TEXT_COLORS: Record<string, string> = {
|
||||||
common: 'text-[var(--text-secondary)]',
|
common: 'text-[var(--text-secondary)]',
|
||||||
uncommon: 'text-[var(--color-success)]',
|
uncommon: 'text-[var(--color-success)]',
|
||||||
rare: 'text-[var(--mana-water)]',
|
rare: 'text-[var(--mana-water)]',
|
||||||
@@ -92,19 +63,57 @@ const RARITY_TEXT_COLORS: Record<string, string> = {
|
|||||||
mythic: 'text-[var(--mana-dark)]',
|
mythic: 'text-[var(--mana-dark)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slot icon mapping using Lucide icons
|
|
||||||
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
|
const SLOT_ICONS: Record<EquipmentSlot, React.ElementType> = {
|
||||||
mainHand: Sword,
|
mainHand: () => (
|
||||||
offHand: Shield,
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
head: HardHat,
|
<path d="M14.5 4H5.5L2 10v10a2 2 0 002 2h16a2 2 0 002-2V10l-3.5-6z" />
|
||||||
body: Shirt,
|
<line x1="6" y1="10" x2="18" y2="10" />
|
||||||
hands: Hand,
|
</svg>
|
||||||
feet: Footprints,
|
),
|
||||||
accessory1: Gem,
|
offHand: () => (
|
||||||
accessory2: Gem,
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20 7H9a2 2 0 01-2-2V4a2 2 0 012-2h5l3 6-3 6h-5a2 2 0 00-2 2v4a2 2 0 002 2h7l3 6-3 6H4a2 2 0 01-2-2v-2" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
head: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
||||||
|
<path d="M14 2v6h6M10 14l2 2 4-4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
body: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M20.38 8.5c.6-1.4 1.3-4.9-1.2-8.5-1.1-1.8-3.6-1.8-4.7 0-2.5 3.6-1.9 7.1-1.2 8.5.7 1.4 2.5 1.9 4 1.9 1.5 0 3.3-.5 4-1.9z" />
|
||||||
|
<path d="M12 8v7M8 18h8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
hands: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M18 11V6a2 2 0 00-2-2h-2a2 2 0 00-2 2v5M6 11v6a2 2 0 002 2h5a2 2 0 002-2v-6" />
|
||||||
|
<path d="M10 15V7a2 2 0 012-2h2a2 2 0 012 2v8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
feet: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2v20M5 13a4 4 0 000 8h14a4 4 0 000-8" />
|
||||||
|
<path d="M9 13V5a2 2 0 012-2h2a2 2 0 012 2v8" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accessory1: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="M12 4v8l2 4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
accessory2: () => (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<circle cx="12" cy="12" r="8" />
|
||||||
|
<path d="M12 4v8l2 4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Slot grouping for visual layout - requirement: visual slot layout
|
// Slot grouping for visual layout
|
||||||
type SlotGroup = {
|
type SlotGroup = {
|
||||||
label: string;
|
label: string;
|
||||||
slots: EquipmentSlot[];
|
slots: EquipmentSlot[];
|
||||||
@@ -116,7 +125,7 @@ const SLOT_GROUPS: SlotGroup[] = [
|
|||||||
{ label: 'Accessories', slots: ['accessory1', 'accessory2'] },
|
{ label: 'Accessories', slots: ['accessory1', 'accessory2'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function EquipmentTab({ store }: EquipmentTabProps) {
|
export function EquipmentTab({ store }: { store: GameStore }) {
|
||||||
const showToast = useGameToast();
|
const showToast = useGameToast();
|
||||||
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
|
const [selectedSlot, setSelectedSlot] = useState<EquipmentSlot | null>(null);
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<{ instanceId: string; name: string } | null>(null);
|
const [deleteConfirm, setDeleteConfirm] = useState<{ instanceId: string; name: string } | null>(null);
|
||||||
@@ -147,18 +156,10 @@ export function EquipmentTab({ store }: EquipmentTabProps) {
|
|||||||
const instanceId = store.equippedInstances[slot];
|
const instanceId = store.equippedInstances[slot];
|
||||||
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
|
const instance = instanceId ? store.equipmentInstances[instanceId] : null;
|
||||||
store.unequipItem(slot);
|
store.unequipItem(slot);
|
||||||
showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed from ${SLOT_NAMES[slot]}`);
|
showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed from ${SLOT_NAMES[slot]}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Get items that can be equipped in a slot
|
// Check if a slot is blocked by a 2-handed weapon
|
||||||
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 (task3 bug #6)
|
|
||||||
const isSlotBlocked = (slot: EquipmentSlot): boolean => {
|
const isSlotBlocked = (slot: EquipmentSlot): boolean => {
|
||||||
if (slot === 'offHand' && store.equippedInstances.mainHand) {
|
if (slot === 'offHand' && store.equippedInstances.mainHand) {
|
||||||
const mainHandInstance = store.equipmentInstances[store.equippedInstances.mainHand];
|
const mainHandInstance = store.equipmentInstances[store.equippedInstances.mainHand];
|
||||||
@@ -169,6 +170,13 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
|
|||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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));
|
||||||
|
};
|
||||||
|
|
||||||
// Get all items that can go in a slot
|
// Get all items that can go in a slot
|
||||||
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
|
const getItemsForSlot = (slot: EquipmentSlot): EquipmentInstance[] => {
|
||||||
if (isSlotBlocked(slot)) return [];
|
if (isSlotBlocked(slot)) return [];
|
||||||
@@ -177,7 +185,6 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
|
|||||||
const accessoryTypeIds = Object.values(EQUIPMENT_TYPES)
|
const accessoryTypeIds = Object.values(EQUIPMENT_TYPES)
|
||||||
.filter((t) => t.category === 'accessory')
|
.filter((t) => t.category === 'accessory')
|
||||||
.map((t) => t.id);
|
.map((t) => t.id);
|
||||||
|
|
||||||
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
|
return unequippedItems.filter((inst) => accessoryTypeIds.includes(inst.typeId));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,145 +198,36 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
|
|||||||
return getEquippableItems(slot);
|
return getEquippableItems(slot);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render a single equipment slot
|
// Check if an instance is currently equipped
|
||||||
const renderSlot = (slot: EquipmentSlot) => {
|
const isEquipped = (instanceId: string): boolean =>
|
||||||
const instanceId = store.equippedInstances[slot];
|
Object.values(store.equippedInstances).includes(instanceId);
|
||||||
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 = (
|
// Get all slots an item type can be equipped to
|
||||||
<GameCard
|
const getEquippableSlots = (typeId: string): EquipmentSlot[] => {
|
||||||
variant={blocked ? 'danger' : instance ? 'default' : 'sunken'}
|
const equipmentType = EQUIPMENT_TYPES[typeId];
|
||||||
className={`relative transition-all duration-200
|
if (!equipmentType) return [];
|
||||||
${isEmpty && !blocked ? 'border-dashed' : ''}
|
if (equipmentType.category === 'accessory') {
|
||||||
${blocked ? 'opacity-60 cursor-not-allowed' : 'hover:border-[var(--border-default)]'}
|
return ['accessory1', 'accessory2'];
|
||||||
`}
|
|
||||||
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)]"
|
|
||||||
>
|
|
||||||
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 [equipmentType.slot];
|
||||||
|
};
|
||||||
|
|
||||||
return <div key={slot}>{slotContent}</div>;
|
// Handle item deletion
|
||||||
|
const handleDelete = (instanceId: string, name: string) => {
|
||||||
|
setDeleteConfirm({ instanceId, name });
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = () => {
|
||||||
|
if (deleteConfirm) {
|
||||||
|
store.deleteEquipmentInstance(deleteConfirm.instanceId);
|
||||||
|
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||||
|
setDeleteConfirm(null);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 max-w-full overflow-x-hidden">
|
<div className="space-y-4 max-w-full overflow-x-hidden">
|
||||||
{/* Equipment Slots - Requirement: Visual slot layout */}
|
{/* Equipment Slots */}
|
||||||
<GameCard variant="default">
|
<GameCard variant="default">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Equipped Gear"
|
title="Equipped Gear"
|
||||||
@@ -339,27 +237,17 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
|
|||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-6">
|
<EquipmentSlotGrid
|
||||||
{/* Render slot groups */}
|
store={store}
|
||||||
{SLOT_GROUPS.map((group) => (
|
selectedSlot={selectedSlot}
|
||||||
<div key={group.label}>
|
onSlotClick={setSelectedSlot}
|
||||||
<h4 className="text-xs font-semibold text-[var(--text-muted)] uppercase tracking-wider mb-2">
|
onUnequip={handleUnequip}
|
||||||
{group.label}
|
isSlotBlocked={isSlotBlocked}
|
||||||
</h4>
|
SLOT_GROUPS={SLOT_GROUPS}
|
||||||
<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>
|
</GameCard>
|
||||||
|
|
||||||
{/* Inventory */}
|
{/* Equipment Inventory */}
|
||||||
<GameCard variant="default">
|
<GameCard variant="default">
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={`Equipment Inventory (${unequippedItems.length} items)`}
|
title={`Equipment Inventory (${unequippedItems.length} items)`}
|
||||||
@@ -369,115 +257,15 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
|
|||||||
No unequipped items. Craft new gear in the Crafting tab.
|
No unequipped items. Craft new gear in the Crafting tab.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 max-h-96 overflow-y-auto">
|
<EquipmentInventory
|
||||||
{unequippedItems.map((instance) => {
|
store={store}
|
||||||
const equipmentType = EQUIPMENT_TYPES[instance.typeId];
|
unequippedItems={unequippedItems}
|
||||||
const validSlots = equipmentType
|
onEquip={handleEquip}
|
||||||
? (equipmentType.category === 'accessory'
|
onDelete={handleDelete}
|
||||||
? ['accessory1', 'accessory2'] as EquipmentSlot[]
|
getEquippableSlots={getEquippableSlots}
|
||||||
: [equipmentType.slot])
|
SLOT_NAMES={SLOT_NAMES}
|
||||||
: [];
|
SLOT_ICONS={SLOT_ICONS}
|
||||||
|
/>
|
||||||
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.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>
|
|
||||||
|
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</GameCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</GameCard>
|
</GameCard>
|
||||||
|
|
||||||
@@ -564,21 +352,15 @@ showToast('success', 'Item Unequipped', `${instance?.name || 'Item'} removed fro
|
|||||||
</GameCard>
|
</GameCard>
|
||||||
|
|
||||||
{/* Delete Confirmation Dialog */}
|
{/* Delete Confirmation Dialog */}
|
||||||
{deleteConfirm && (
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteConfirm}
|
open={!!deleteConfirm}
|
||||||
onOpenChange={() => setDeleteConfirm(null)}
|
onOpenChange={() => setDeleteConfirm(null)}
|
||||||
title="Discard Item?"
|
title="Discard Item?"
|
||||||
description={`Discard ${deleteConfirm.name}? This cannot be undone.`}
|
description={`Discard ${deleteConfirm?.name}? This cannot be undone.`}
|
||||||
variant="danger"
|
variant="danger"
|
||||||
confirmText="Discard"
|
confirmText="Discard"
|
||||||
onConfirm={() => {
|
onConfirm={confirmDelete}
|
||||||
store.deleteEquipmentInstance(deleteConfirm.instanceId);
|
|
||||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
|
||||||
setDeleteConfirm(null);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user