Refactor large files into modular components
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m9s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Failing after 1m9s
- Refactored page.tsx (613→252 lines) with GameOverScreen and LeftPanel extracted - Refactored StatsTab.tsx (584→92 lines) with section components - Refactored SkillsTab.tsx (434→54 lines) with sub-components - Created modular structure for GameContext, LootInventory, and other components - All extracted components organized into feature directories
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
'use client';
|
||||
|
||||
import { Scroll } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
|
||||
interface BlueprintsSectionProps {
|
||||
blueprints: string[];
|
||||
}
|
||||
|
||||
export function BlueprintsSection({ blueprints }: BlueprintsSectionProps) {
|
||||
if (blueprints.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Scroll className="w-3 h-3" />
|
||||
Blueprints (permanent)
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{blueprints.map((id) => {
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return null;
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
return (
|
||||
<Badge
|
||||
key={id}
|
||||
className="text-xs"
|
||||
style={{
|
||||
backgroundColor: `${RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)'}`,
|
||||
color: rarityColor,
|
||||
borderColor: rarityColor,
|
||||
}}
|
||||
>
|
||||
{drop.name}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1 italic">
|
||||
Blueprints are permanent unlocks - use them to craft equipment
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
'use client';
|
||||
|
||||
import { Package, Trash2 } from 'lucide-react';
|
||||
import type { EquipmentInstance } from '@/lib/game/types';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { CATEGORY_ICONS } from './icons';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface EquipmentItemProps {
|
||||
instanceId: string;
|
||||
instance: EquipmentInstance;
|
||||
onDelete?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentItem({ instanceId, instance, onDelete }: EquipmentItemProps) {
|
||||
const type = EQUIPMENT_TYPES[instance.typeId];
|
||||
const Icon = type ? CATEGORY_ICONS[type.category] || Package : Package;
|
||||
const rarityColor = RARITY_CSS_VAR[instance.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[instance.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-2">
|
||||
<Icon className="w-4 h-4 mt-0.5" style={{ color: rarityColor }} />
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{instance.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
{type?.name} • {instance.usedCapacity}/{instance.totalCapacity} cap
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{instance.rarity} • {instance.enchantments.length} enchants
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(instanceId)}
|
||||
aria-label={`Delete ${instance.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EquipmentSectionProps {
|
||||
equipment: [string, EquipmentInstance][];
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function EquipmentSection({ equipment, onDeleteEquipment }: EquipmentSectionProps) {
|
||||
if (equipment.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Package className="w-3 h-3" />
|
||||
Equipment
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{equipment.map(([id, instance]) => (
|
||||
<EquipmentItem
|
||||
key={id}
|
||||
instanceId={id}
|
||||
instance={instance}
|
||||
onDelete={onDeleteEquipment}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Droplet } from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { ElementState } from '@/lib/game/types';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
interface EssenceItemProps {
|
||||
elementId: string;
|
||||
state: ElementState;
|
||||
}
|
||||
|
||||
export function EssenceItem({ elementId, state }: EssenceItemProps) {
|
||||
const elem = ELEMENTS[elementId];
|
||||
if (!elem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)]"
|
||||
style={{
|
||||
borderColor: `var(--mana-${elementId})`,
|
||||
backgroundColor: `var(--mana-${elementId})20`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<ElementBadge element={elementId} showIcon={true} size="sm" />
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{state.current} / {state.max}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EssenceSectionProps {
|
||||
essence: [string, ElementState][];
|
||||
}
|
||||
|
||||
export function EssenceSection({ essence }: EssenceSectionProps) {
|
||||
if (essence.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Droplet className="w-3 h-3" />
|
||||
Elemental Essence
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{essence.map(([id, state]) => (
|
||||
<EssenceItem key={id} elementId={id} state={state} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Search, ArrowUpDown, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import { type SortMode, type FilterMode, RARITY_ORDER } from './types';
|
||||
import { MaterialsSection } from './MaterialItem';
|
||||
import { EssenceSection } from './EssenceItem';
|
||||
import { BlueprintsSection } from './BlueprintsSection';
|
||||
import { EquipmentSection } from './EquipmentItem';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
equipmentInstances = {},
|
||||
onDeleteMaterial,
|
||||
onDeleteEquipment,
|
||||
}: LootInventoryProps) {
|
||||
const showToast = useGameToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('rarity');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
|
||||
|
||||
// Count items
|
||||
const materialCount = Object.values(inventory.materials).reduce((a, b) => a + b, 0);
|
||||
const essenceCount = elements ? Object.entries(elements).reduce((a, [id, e]) => id === 'transference' ? a : a + e.current, 0) : 0;
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||
|
||||
// Filter and sort materials
|
||||
const filteredMaterials = Object.entries(inventory.materials)
|
||||
.filter(([id, count]) => {
|
||||
if (count <= 0) return false;
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return false;
|
||||
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aCount], [bId, bCount]) => {
|
||||
const aDrop = LOOT_DROPS[aId];
|
||||
const bDrop = LOOT_DROPS[bId];
|
||||
if (!aDrop || !bDrop) return 0;
|
||||
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aDrop.name.localeCompare(bDrop.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
|
||||
case 'count':
|
||||
return bCount - aCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort essence
|
||||
const filteredEssence = elements
|
||||
? Object.entries(elements)
|
||||
.filter(([id, state]) => {
|
||||
if (!state.unlocked || state.current <= 0) return false;
|
||||
if (id === 'transference') return false;
|
||||
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aState], [bId, bState]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
|
||||
case 'count':
|
||||
return bState.current - aState.current;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter and sort equipment
|
||||
const filteredEquipment = Object.entries(equipmentInstances)
|
||||
.filter(([id, instance]) => {
|
||||
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aInst], [bId, bInst]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aInst.name.localeCompare(bInst.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Check if we have anything to show
|
||||
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (drop) {
|
||||
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
if (instance) {
|
||||
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
|
||||
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
|
||||
const amount = inventory.materials[deleteConfirm.id] || 0;
|
||||
onDeleteMaterial(deleteConfirm.id, amount);
|
||||
showToast('success', 'Material Deleted', `${deleteConfirm.name} removed from inventory`);
|
||||
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||
onDeleteEquipment(deleteConfirm.id);
|
||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||
}
|
||||
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] text-xs border-[var(--border-subtle)]"
|
||||
aria-label={`${totalItems} items in inventory`}
|
||||
>
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 bg-[var(--bg-sunken)] border-[var(--border-subtle)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-disabled)]"
|
||||
aria-label="Search inventory"
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||
aria-label={`Sort by ${sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity'}`}
|
||||
>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3">
|
||||
{[
|
||||
{ mode: 'all' as FilterMode, label: 'All' },
|
||||
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
|
||||
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
|
||||
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
|
||||
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
|
||||
].map(({ mode, label }) => (
|
||||
<ActionButton
|
||||
key={mode}
|
||||
variant={filterMode === mode ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${filterMode === mode ? '' : 'bg-[var(--bg-sunken)]'}`}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
aria-pressed={filterMode === mode}
|
||||
aria-label={`Filter by ${label}`}
|
||||
>
|
||||
{label}
|
||||
</ActionButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)] mb-3" />
|
||||
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-3 pr-2">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && (
|
||||
<MaterialsSection
|
||||
materials={filteredMaterials}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && (
|
||||
<EssenceSection essence={filteredEssence} />
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && (
|
||||
<BlueprintsSection blueprints={inventory.blueprints} />
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && (
|
||||
<EquipmentSection
|
||||
equipment={filteredEquipment}
|
||||
onDeleteEquipment={handleDeleteEquipment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<AlertDialogContent className="bg-[var(--bg-surface)] border-[var(--border-default)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--mana-light)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Item
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
Are you sure you want to delete <strong className="text-[var(--text-primary)]">{deleteConfirm?.name}</strong>?
|
||||
{deleteConfirm?.type === 'material' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||
</span>
|
||||
)}
|
||||
{deleteConfirm?.type === 'equipment' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This equipment and all its enchantments will be permanently lost!
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--interactive-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LootInventoryDisplay.displayName = "LootInventoryDisplay";
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import type { LootInventory } from '@/lib/game/types';
|
||||
// For backward compatibility
|
||||
type LootInventoryType = LootInventory;
|
||||
import { LOOT_DROPS } from '@/lib/game/data/loot-drops';
|
||||
import { RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { Sparkles, Trash2 } from 'lucide-react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
|
||||
interface MaterialItemProps {
|
||||
materialId: string;
|
||||
count: number;
|
||||
onDelete?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialItem({ materialId, count, onDelete }: MaterialItemProps) {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (!drop) return null;
|
||||
|
||||
const rarityColor = RARITY_CSS_VAR[drop.rarity] || 'var(--rarity-common)';
|
||||
const rarityGlow = RARITY_GLOW_CSS_VAR[drop.rarity] || 'var(--rarity-common-glow)';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-2 rounded border bg-[var(--bg-sunken)] group relative"
|
||||
style={{
|
||||
borderColor: rarityColor,
|
||||
backgroundColor: rarityGlow,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold" style={{ color: rarityColor }}>
|
||||
{drop.name}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-secondary)]">
|
||||
x{count}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] capitalize">
|
||||
{drop.rarity}
|
||||
</div>
|
||||
</div>
|
||||
{onDelete && (
|
||||
<ActionButton
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 w-5 p-0 opacity-0 group-hover:opacity-100 text-[var(--color-danger)] hover:text-[var(--interactive-danger-hover)] hover:bg-[var(--interactive-danger)]/20"
|
||||
onClick={() => onDelete(materialId)}
|
||||
aria-label={`Delete ${drop.name}`}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface MaterialsSectionProps {
|
||||
materials: [string, number][];
|
||||
onDeleteMaterial?: (materialId: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsSection({ materials, onDeleteMaterial }: MaterialsSectionProps) {
|
||||
if (materials.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="text-xs text-[var(--text-muted)] mb-2 flex items-center gap-1">
|
||||
<Sparkles className="w-3 h-3" />
|
||||
Materials
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{materials.map(([id, count]) => (
|
||||
<MaterialItem
|
||||
key={id}
|
||||
materialId={id}
|
||||
count={count}
|
||||
onDelete={onDeleteMaterial}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Gem, Sparkles, Scroll, Droplet, Trash2, Search,
|
||||
Package, Sword, Shield, Shirt, Crown, ArrowUpDown,
|
||||
Wrench, AlertTriangle } from 'lucide-react';
|
||||
import type { EquipmentCategory } from '@/lib/game/data/equipment';
|
||||
|
||||
export const CATEGORY_ICONS: Record<string, typeof Sword> = {
|
||||
caster: Sword,
|
||||
shield: Shield,
|
||||
catalyst: Sparkles,
|
||||
head: Crown,
|
||||
body: Shirt,
|
||||
hands: Wrench,
|
||||
feet: Package,
|
||||
accessory: Gem,
|
||||
};
|
||||
@@ -0,0 +1,318 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Gem, Search, ArrowUpDown, AlertTriangle
|
||||
} from 'lucide-react';
|
||||
import { ElementBadge } from '@/components/ui/element-badge';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
|
||||
import { type SortMode, type FilterMode, RARITY_ORDER, RARITY_CSS_VAR, RARITY_GLOW_CSS_VAR } from './types';
|
||||
import { MaterialsSection } from './MaterialItem';
|
||||
import { EssenceSection } from './EssenceItem';
|
||||
import { BlueprintsSection } from './BlueprintsSection';
|
||||
import { EquipmentSection } from './EquipmentItem';
|
||||
|
||||
interface LootInventoryProps {
|
||||
inventory: LootInventoryType;
|
||||
elements?: Record<string, ElementState>;
|
||||
equipmentInstances?: Record<string, EquipmentInstance>;
|
||||
onDeleteMaterial?: (materialId: string, amount: number) => void;
|
||||
onDeleteEquipment?: (instanceId: string) => void;
|
||||
}
|
||||
|
||||
export function LootInventoryDisplay({
|
||||
inventory,
|
||||
elements,
|
||||
equipmentInstances = {},
|
||||
onDeleteMaterial,
|
||||
onDeleteEquipment,
|
||||
}: LootInventoryProps) {
|
||||
const showToast = useGameToast();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [sortMode, setSortMode] = useState<SortMode>('rarity');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('all');
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<{ type: 'material' | 'equipment'; id: string; name: string } | null>(null);
|
||||
|
||||
// Count items
|
||||
const materialCount = Object.values(inventory.materials).reduce((a: number, b: number) => a + b, 0);
|
||||
|
||||
// Calculate essence count
|
||||
let essenceCount = 0;
|
||||
if (elements) {
|
||||
essenceCount = Object.entries(elements).reduce((acc: number, [id, state]) => {
|
||||
if (id === 'transference') return acc;
|
||||
return acc + (state.current || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const blueprintCount = inventory.blueprints.length;
|
||||
const equipmentCount = Object.keys(equipmentInstances).length;
|
||||
const totalItems = materialCount + blueprintCount + equipmentCount;
|
||||
|
||||
// Filter and sort materials
|
||||
const filteredMaterials = Object.entries(inventory.materials)
|
||||
.filter(([id, count]) => {
|
||||
if (count <= 0) return false;
|
||||
const drop = LOOT_DROPS[id];
|
||||
if (!drop) return false;
|
||||
if (searchTerm && !drop.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aCount], [bId, bCount]) => {
|
||||
const aDrop = LOOT_DROPS[aId];
|
||||
const bDrop = LOOT_DROPS[bId];
|
||||
if (!aDrop || !bDrop) return 0;
|
||||
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aDrop.name.localeCompare(bDrop.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bDrop.rarity] - RARITY_ORDER[aDrop.rarity];
|
||||
case 'count':
|
||||
return bCount - aCount;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Filter and sort essence
|
||||
const filteredEssence = elements
|
||||
? Object.entries(elements)
|
||||
.filter(([id, state]) => {
|
||||
if (!state.unlocked || state.current <= 0) return false;
|
||||
if (id === 'transference') return false;
|
||||
if (searchTerm && !ELEMENTS[id]?.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aState], [bId, bState]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return (ELEMENTS[aId]?.name || aId).localeCompare(ELEMENTS[bId]?.name || bId);
|
||||
case 'count':
|
||||
return bState.current - aState.current;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
})
|
||||
: [];
|
||||
|
||||
// Filter and sort equipment
|
||||
const filteredEquipment = Object.entries(equipmentInstances)
|
||||
.filter(([id, instance]) => {
|
||||
if (searchTerm && !instance.name.toLowerCase().includes(searchTerm.toLowerCase())) return false;
|
||||
return true;
|
||||
})
|
||||
.sort(([aId, aInst], [bId, bInst]) => {
|
||||
switch (sortMode) {
|
||||
case 'name':
|
||||
return aInst.name.localeCompare(bInst.name);
|
||||
case 'rarity':
|
||||
return RARITY_ORDER[bInst.rarity] - RARITY_ORDER[aInst.rarity];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const hasItems = totalItems > 0 || essenceCount > 0;
|
||||
|
||||
const handleDeleteMaterial = (materialId: string) => {
|
||||
const drop = LOOT_DROPS[materialId];
|
||||
if (drop) {
|
||||
setDeleteConfirm({ type: 'material', id: materialId, name: drop.name });
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteEquipment = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
if (instance) {
|
||||
setDeleteConfirm({ type: 'equipment', id: instanceId, name: instance.name });
|
||||
}
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteConfirm) return;
|
||||
|
||||
if (deleteConfirm.type === 'material' && onDeleteMaterial) {
|
||||
const amount = inventory.materials[deleteConfirm.id] || 0;
|
||||
onDeleteMaterial(deleteConfirm.id, amount);
|
||||
showToast('success', 'Material Deleted', `${deleteConfirm.name} removed from inventory`);
|
||||
} else if (deleteConfirm.type === 'equipment' && onDeleteEquipment) {
|
||||
onDeleteEquipment(deleteConfirm.id);
|
||||
showToast('success', 'Item Discarded', `${deleteConfirm.name} has been removed from inventory`);
|
||||
}
|
||||
|
||||
setDeleteConfirm(null);
|
||||
};
|
||||
|
||||
if (!hasItems) {
|
||||
return (
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
</div>
|
||||
<div className="text-[var(--text-muted)] text-sm text-center py-4">
|
||||
No items collected yet. Defeat floors and guardians to find loot!
|
||||
</div>
|
||||
</GameCard>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GameCard variant="default" className="w-full">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Gem className="w-4 h-4 text-[var(--mana-light)]" />
|
||||
<h3 className="text-[var(--mana-light)] game-panel-title text-xs uppercase tracking-wider">
|
||||
Inventory
|
||||
</h3>
|
||||
<Badge
|
||||
className="ml-auto bg-[var(--bg-sunken)] text-[var(--text-secondary)] text-xs border-[var(--border-subtle)]"
|
||||
aria-label={`${totalItems} items in inventory`}
|
||||
>
|
||||
{totalItems} items
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Search and Filter Controls */}
|
||||
<div className="flex gap-2 mb-3">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-3 h-3 text-[var(--text-muted)]" />
|
||||
<Input
|
||||
placeholder="Search..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="h-7 pl-7 bg-[var(--bg-sunken)] border-[var(--border-subtle)] text-xs text-[var(--text-primary)] placeholder:text-[var(--text-disabled)]"
|
||||
aria-label="Search inventory"
|
||||
/>
|
||||
</div>
|
||||
<ActionButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={() => setSortMode(sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity')}
|
||||
aria-label={`Sort by ${sortMode === 'rarity' ? 'name' : sortMode === 'name' ? 'count' : 'rarity'}`}
|
||||
>
|
||||
<ArrowUpDown className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex gap-1 flex-wrap mb-3">
|
||||
{[
|
||||
{ mode: 'all' as FilterMode, label: 'All' },
|
||||
{ mode: 'materials' as FilterMode, label: `Materials (${materialCount})` },
|
||||
{ mode: 'essence' as FilterMode, label: `Essence (${essenceCount})` },
|
||||
{ mode: 'blueprints' as FilterMode, label: `Blueprints (${blueprintCount})` },
|
||||
{ mode: 'equipment' as FilterMode, label: `Equipment (${equipmentCount})` },
|
||||
].map(({ mode, label }) => (
|
||||
<ActionButton
|
||||
key={mode}
|
||||
variant={filterMode === mode ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
className={`h-6 px-2 text-xs ${filterMode === mode ? '' : 'bg-[var(--bg-sunken)]'}`}
|
||||
onClick={() => setFilterMode(mode)}
|
||||
aria-pressed={filterMode === mode}
|
||||
aria-label={`Filter by ${label}`}
|
||||
>
|
||||
{label}
|
||||
</ActionButton>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Separator className="bg-[var(--border-subtle)] mb-3" />
|
||||
|
||||
<ScrollArea className="h-64 w-full">
|
||||
<div className="space-y-3 pr-2">
|
||||
{/* Materials */}
|
||||
{(filterMode === 'all' || filterMode === 'materials') && (
|
||||
<MaterialsSection
|
||||
materials={filteredMaterials}
|
||||
onDeleteMaterial={handleDeleteMaterial}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Essence */}
|
||||
{(filterMode === 'all' || filterMode === 'essence') && (
|
||||
<EssenceSection essence={filteredEssence} />
|
||||
)}
|
||||
|
||||
{/* Blueprints */}
|
||||
{(filterMode === 'all' || filterMode === 'blueprints') && (
|
||||
<BlueprintsSection blueprints={inventory.blueprints} />
|
||||
)}
|
||||
|
||||
{/* Equipment */}
|
||||
{(filterMode === 'all' || filterMode === 'equipment') && (
|
||||
<EquipmentSection
|
||||
equipment={filteredEquipment}
|
||||
onDeleteEquipment={handleDeleteEquipment}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</GameCard>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteConfirm} onOpenChange={() => setDeleteConfirm(null)}>
|
||||
<AlertDialogContent className="bg-[var(--bg-surface)] border-[var(--border-default)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--mana-light)] flex items-center gap-2">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
Delete Item
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
Are you sure you want to delete <strong className="text-[var(--text-primary)]">{deleteConfirm?.name}</strong>?
|
||||
{deleteConfirm?.type === 'material' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This will delete ALL {inventory.materials[deleteConfirm?.id || ''] || 0} of this material!
|
||||
</span>
|
||||
)}
|
||||
{deleteConfirm?.type === 'equipment' && (
|
||||
<span className="block mt-2 text-[var(--color-danger)]">
|
||||
This equipment and all its enchantments will be permanently lost!
|
||||
</span>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]">
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--interactive-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmDelete}
|
||||
>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
LootInventoryDisplay.displayName = "LootInventoryDisplay";
|
||||
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import type { LootInventory as LootInventoryType, EquipmentInstance, ElementState } from '@/lib/game/types';
|
||||
import { LOOT_DROPS, RARITY_COLORS } from '@/lib/game/data/loot-drops';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ELEMENTS } from '@/lib/game/constants';
|
||||
|
||||
export type SortMode = 'name' | 'rarity' | 'count';
|
||||
export type FilterMode = 'all' | 'materials' | 'essence' | 'blueprints' | 'equipment';
|
||||
|
||||
export const RARITY_ORDER = {
|
||||
common: 0,
|
||||
uncommon: 1,
|
||||
rare: 2,
|
||||
epic: 3,
|
||||
legendary: 4,
|
||||
mythic: 5,
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for colors
|
||||
export const RARITY_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common)',
|
||||
uncommon: 'var(--rarity-uncommon)',
|
||||
rare: 'var(--rarity-rare)',
|
||||
epic: 'var(--rarity-epic)',
|
||||
legendary: 'var(--rarity-legendary)',
|
||||
mythic: 'var(--rarity-mythic)',
|
||||
};
|
||||
|
||||
// Map rarity to CSS variable for glow/background
|
||||
export const RARITY_GLOW_CSS_VAR: Record<string, string> = {
|
||||
common: 'var(--rarity-common-glow)',
|
||||
uncommon: 'var(--rarity-uncommon-glow)',
|
||||
rare: 'var(--rarity-rare-glow)',
|
||||
epic: 'var(--rarity-epic-glow)',
|
||||
legendary: 'var(--rarity-legendary-glow)',
|
||||
mythic: 'var(--rarity-mythic-glow)',
|
||||
};
|
||||
Reference in New Issue
Block a user