Files
Mana-Loop/src/components/game/LootInventory/index.tsx
T
Refactoring Agent 0eabd604b0
Build and Publish Mana Loop Docker Image / build-and-publish (push) Has been cancelled
fix: Object.values null safety + Docker dev build
- Change Dockerfile to use development build (better error messages)
- Add || {} fallbacks to all Object.values() calls accessing state
- Fixes "Cannot convert undefined or null to object" browser error during SSR/hydration
- Verified TypeScript compilation and Next.js build successful

Files modified:
- Dockerfile
- src/app/page.tsx
- src/components/game/tabs/StatsTab.tsx
- src/components/game/StatsTab/LoopStatsSection.tsx
- src/components/game/StatsTab/ElementStatsSection.tsx
- src/components/game/tabs/AttunementsTab.tsx
- src/components/game/tabs/GolemancyTab.tsx
- src/lib/game/effects.ts
- src/lib/game/utils/combat-utils.ts
- src/lib/game/crafting-loot.ts
- src/components/game/LootInventory/LootInventoryDisplay.tsx
- src/components/game/LootInventory/index.tsx
- src/components/game/crafting/EnchantmentDesigner/utils.ts
2026-05-04 11:03:11 +02:00

319 lines
12 KiB
TypeScript

'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";