feat(ui): complete Task 4 UI redesign — all sub-tasks 1-10
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 8m47s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 8m47s
- Implemented complete design system with 40+ CSS custom properties - Created 9 UI primitives (GameCard, SectionHeader, StatRow, ManaBar, ElementBadge, ValueDisplay, ActionButton, SkillRow, TooltipInfo) - Redesigned all tabs: Spire, Skills, Stats, Equipment, Crafting, Attunements, Golemancy, Spells, Loot, Achievements, Lab, Debug - Added toast notification system (GameToast) with success/warning/error/info types - Added confirmation dialogs for destructive actions - Removed all dev artifacts and component name labels - Added empty states to all tabs - Replaced emoji icons with Lucide React icons - Added enchantPower placeholder to StatsTab and EquipmentTab - Mobile audit passed at 375px viewport - Build passes with 0 errors, lint passes with 0 errors Sub-tasks completed: - ST1: Design System Implementation - ST2: Global Layout & Header - ST3: Left Panel (Mana Display & Action Area) - ST4: Skills Tab - ST5: Spire Tab & Spire Mode UI - ST6: Stats Tab - ST7: Equipment & Crafting Tabs - ST8: Attunements Tab - ST9: Remaining Tabs - ST10: Toast System & Confirmation Dialogs Documentation: 15+ files in docs/task4/
This commit is contained in:
@@ -1,26 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { useState } from 'react';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog';
|
||||
import { Trash2, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import type { EquipmentInstance, AppliedEnchantment, LootInventory, EquipmentCraftingProgress, EquipmentSlot } from '@/lib/game/types';
|
||||
import { fmt, type GameStore } from '@/lib/game/store';
|
||||
|
||||
// Slot display names
|
||||
const SLOT_NAMES: Record<EquipmentSlot, string> = {
|
||||
mainHand: 'Main Hand',
|
||||
offHand: 'Off Hand',
|
||||
head: 'Head',
|
||||
body: 'Body',
|
||||
hands: 'Hands',
|
||||
feet: 'Feet',
|
||||
accessory1: 'Accessory 1',
|
||||
accessory2: 'Accessory 2',
|
||||
};
|
||||
import { useGameToast } from '@/components/game/GameToast';
|
||||
|
||||
export interface EnchantmentPreparerProps {
|
||||
store: GameStore;
|
||||
@@ -33,6 +26,7 @@ export function EnchantmentPreparer({
|
||||
selectedEquipmentInstance,
|
||||
setSelectedEquipmentInstance,
|
||||
}: EnchantmentPreparerProps) {
|
||||
const showToast = useGameToast();
|
||||
const equippedInstances = store.equippedInstances;
|
||||
const equipmentInstances = store.equipmentInstances;
|
||||
const preparationProgress = store.preparationProgress;
|
||||
@@ -49,170 +43,263 @@ export function EnchantmentPreparer({
|
||||
instance: equipmentInstances[instanceId!],
|
||||
}));
|
||||
|
||||
// Confirm dialog state
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
|
||||
const handleStartPreparation = () => {
|
||||
if (!selectedEquipmentInstance) return;
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
if (!instance) return;
|
||||
|
||||
// If item has existing enchantments, show confirm dialog (bug #8)
|
||||
if (instance.enchantments.length > 0) {
|
||||
setShowConfirmDialog(true);
|
||||
} else {
|
||||
startPreparingWithToast(selectedEquipmentInstance);
|
||||
}
|
||||
};
|
||||
|
||||
const startPreparingWithToast = (instanceId: string) => {
|
||||
const instance = equipmentInstances[instanceId];
|
||||
startPreparing(instanceId);
|
||||
if (instance) {
|
||||
showToast('info', 'Preparation Started', `Preparing ${instance.name} for enchantment...`);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmPreparation = () => {
|
||||
if (selectedEquipmentInstance) {
|
||||
startPreparingWithToast(selectedEquipmentInstance);
|
||||
setShowConfirmDialog(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Equipment Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Select Equipment to Prepare</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{preparationProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
|
||||
</div>
|
||||
<Progress value={(preparationProgress.progress / preparationProgress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={cancelPreparation}>Cancel</Button>
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Select Equipment to Prepare" />
|
||||
{preparationProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Preparing: {equipmentInstances[preparationProgress.equipmentInstanceId]?.name}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{equippedItems.map(({ slot, instance }) => {
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
return (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-3 rounded border cursor-pointer transition-all ${
|
||||
selectedEquipmentInstance === instance.instanceId
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
} ${hasEnchantments ? 'border-l-4 border-l-red-600' : ''} ${isReady ? 'border-l-4 border-l-green-600' : ''}`}
|
||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<div className="font-semibold">{instance.name}</div>
|
||||
<div className="text-xs text-gray-400">{SLOT_NAMES[slot]}</div>
|
||||
{hasEnchantments && (
|
||||
<div className="text-xs text-red-400 mt-1">
|
||||
⚠️ {instance.enchantments.length} enchantments - Preparation will remove them
|
||||
</div>
|
||||
)}
|
||||
{isReady && (
|
||||
<div className="text-xs text-green-400 mt-1">
|
||||
✅ Ready for Enchantment
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-green-400">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
|
||||
<div className="text-xs text-gray-400">{instance.enchantments.length} enchants</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{equippedItems.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-4">No equipped items</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Preparation Details */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Preparation Details</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!selectedEquipmentInstance ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Select equipment to prepare
|
||||
<div className="h-3 bg-[var(--bg-sunken)] rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[var(--color-warning)] transition-all duration-300"
|
||||
style={{ width: `${(preparationProgress.progress / preparationProgress.required) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
) : preparationProgress ? (
|
||||
<div className="text-gray-400">Preparation in progress...</div>
|
||||
) : (
|
||||
(() => {
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
if (!instance) return null;
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
||||
const manaCost = instance.totalCapacity * 10;
|
||||
|
||||
// Calculate disenchant recovery
|
||||
const recoveryRate = 0.1; // Base recovery rate (disenchanting skill removed)
|
||||
const totalRecoverable = instance.enchantments.reduce(
|
||||
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-semibold">{instance.name}</div>
|
||||
<Separator className="bg-gray-700" />
|
||||
|
||||
{/* Show warning if item has enchantments */}
|
||||
{hasEnchantments && !isReady && (
|
||||
<div className="p-3 rounded border border-red-600/50 bg-red-900/20">
|
||||
<div className="text-sm font-semibold text-red-400">⚠️ Equipment has enchantments</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
Preparation will remove all existing enchantments and recover some mana.
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-gray-400">Recoverable Mana:</span>
|
||||
<span className="text-green-400">{fmt(totalRecoverable)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show ready status */}
|
||||
{isReady && (
|
||||
<div className="p-3 rounded border border-green-600/50 bg-green-900/20">
|
||||
<div className="text-sm font-semibold text-green-400">✅ Ready for Enchantment</div>
|
||||
<div className="text-xs text-gray-400 mt-1">
|
||||
This item has been prepared and is ready for enchantment application.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{preparationProgress.progress.toFixed(1)}h / {preparationProgress.required.toFixed(1)}h</span>
|
||||
<span>Mana paid: {fmt(preparationProgress.manaCostPaid)}</span>
|
||||
</div>
|
||||
<ActionButton size="sm" variant="outline" onClick={() => {
|
||||
cancelPreparation();
|
||||
showToast('warning', 'Preparation Cancelled', 'Equipment preparation was cancelled.');
|
||||
}}>Cancel</ActionButton>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="space-y-2">
|
||||
{equippedItems.map(({ slot, instance }) => {
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
return (
|
||||
<div
|
||||
key={instance.instanceId}
|
||||
className={`p-3 rounded border cursor-pointer transition-all
|
||||
${selectedEquipmentInstance === instance.instanceId
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}
|
||||
${hasEnchantments ? 'border-l-4 border-l-[var(--color-danger)]' : ''}
|
||||
${isReady ? 'border-l-4 border-l-[var(--color-success)]' : ''}
|
||||
`}
|
||||
onClick={() => setSelectedEquipmentInstance(instance.instanceId)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`${instance.name}${hasEnchantments ? ' (has enchantments)' : ''}${isReady ? ' (ready for enchantment)' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Capacity:</span>
|
||||
<span>{instance.usedCapacity}/{instance.totalCapacity}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Prep Time:</span>
|
||||
<span>{prepTime}h</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-400">Mana Cost:</span>
|
||||
<span className={rawMana < manaCost ? 'text-red-400' : 'text-green-400'}>
|
||||
{fmt(manaCost)}
|
||||
</span>
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">{instance.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{slot}</div>
|
||||
{hasEnchantments && (
|
||||
<div className="text-xs text-[var(--color-danger)] mt-1">
|
||||
<AlertTriangle size={12} className="inline mr-1" />
|
||||
{instance.enchantments.length} enchantments - Preparation will remove them
|
||||
</div>
|
||||
)}
|
||||
{isReady && (
|
||||
<div className="text-xs text-[var(--color-success)] mt-1">
|
||||
<CheckCircle size={12} className="inline mr-1" />
|
||||
Ready for Enchantment
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right text-sm">
|
||||
<div className="text-[var(--color-success)]">{instance.usedCapacity}/{instance.totalCapacity} cap</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{instance.enchantments.length} enchants</div>
|
||||
{/* Requirement: Visual badge for 'Ready for Enchantment' */}
|
||||
{isReady && (
|
||||
<Badge className="mt-1 bg-[var(--color-success)]/20 text-[var(--color-success)] border-[var(--color-success)]/40">
|
||||
<CheckCircle size={10} className="mr-1" />
|
||||
Ready
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{equippedItems.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] py-4">No equipped items</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={rawMana < manaCost || isReady}
|
||||
onClick={() => startPreparing(selectedEquipmentInstance)}
|
||||
>
|
||||
{hasEnchantments ? (
|
||||
<>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
Start Preparation — this will remove existing enchantments ({prepTime}h, {fmt(manaCost)} mana)
|
||||
</>
|
||||
) : (
|
||||
<>Start Preparation ({prepTime}h, {fmt(manaCost)} mana)</>
|
||||
)}
|
||||
</Button>
|
||||
{/* Preparation Details */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="Preparation Details" />
|
||||
{!selectedEquipmentInstance ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select equipment to prepare
|
||||
</div>
|
||||
) : preparationProgress ? (
|
||||
<div className="text-[var(--text-secondary)]">Preparation in progress...</div>
|
||||
) : (
|
||||
(() => {
|
||||
const instance = equipmentInstances[selectedEquipmentInstance];
|
||||
if (!instance) return null;
|
||||
const hasEnchantments = instance.enchantments.length > 0;
|
||||
const isReady = instance.tags?.includes('Ready for Enchantment');
|
||||
const prepTime = 2 + Math.floor(instance.totalCapacity / 50);
|
||||
const manaCost = instance.totalCapacity * 10;
|
||||
|
||||
// Calculate disenchant recovery
|
||||
const recoveryRate = 0.1; // Base recovery rate
|
||||
const totalRecoverable = instance.enchantments.reduce(
|
||||
(sum, e) => sum + Math.floor(e.actualCost * recoveryRate),
|
||||
0
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-semibold text-[var(--text-primary)]">{instance.name}</div>
|
||||
<Separator className="bg-[var(--border-subtle)]" />
|
||||
|
||||
{/* Show warning if item has enchantments - Requirement: button reads "Prepare — removes existing enchantments" */}
|
||||
{hasEnchantments && !isReady && (
|
||||
<div className="p-3 rounded border border-[var(--color-danger)]/50 bg-[var(--color-danger)]/10">
|
||||
<div className="text-sm font-semibold text-[var(--color-danger)]">
|
||||
<AlertTriangle size={14} className="inline mr-1" />
|
||||
Equipment has enchantments
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
||||
Preparation will remove all existing enchantments and recover some mana.
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mt-2">
|
||||
<span className="text-[var(--text-muted)]">Recoverable Mana:</span>
|
||||
<span className="text-[var(--color-success)]">{fmt(totalRecoverable)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show ready status */}
|
||||
{isReady && (
|
||||
<div className="p-3 rounded border border-[var(--color-success)]/50 bg-[var(--color-success)]/10">
|
||||
<div className="text-sm font-semibold text-[var(--color-success)]">
|
||||
<CheckCircle size={14} className="inline mr-1" />
|
||||
Ready for Enchantment
|
||||
</div>
|
||||
<div className="text-xs text-[var(--text-muted)] mt-1">
|
||||
This item has been prepared and is ready for enchantment application.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<StatRow
|
||||
label="Capacity:"
|
||||
value={`${instance.usedCapacity}/${instance.totalCapacity}`}
|
||||
highlight="default"
|
||||
/>
|
||||
<StatRow
|
||||
label="Prep Time:"
|
||||
value={`${prepTime}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<StatRow
|
||||
label="Mana Cost:"
|
||||
value={
|
||||
<span className={rawMana < manaCost ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
||||
{fmt(manaCost)}
|
||||
</span>
|
||||
}
|
||||
highlight={rawMana < manaCost ? 'danger' : 'success'}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Requirement (bug #8): Confirm dialog before proceeding if item has enchantments */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogTrigger asChild>
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={rawMana < manaCost || isReady}
|
||||
onClick={handleStartPreparation}
|
||||
>
|
||||
{hasEnchantments ? (
|
||||
<>
|
||||
<Trash2 size={16} className="mr-2" />
|
||||
Prepare — removes existing enchantments ({prepTime}h, {fmt(manaCost)} mana)
|
||||
</>
|
||||
) : (
|
||||
<>Start Preparation ({prepTime}h, {fmt(manaCost)} mana)</>
|
||||
)}
|
||||
</ActionButton>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-[var(--color-danger)]">
|
||||
<AlertTriangle className="inline mr-2" size={18} />
|
||||
Confirm Preparation
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[var(--text-secondary)]">
|
||||
This equipment has {instance.enchantments.length} existing enchantment(s). Preparation will
|
||||
<strong className="text-[var(--color-danger)]"> permanently remove</strong> all existing enchantments
|
||||
and recover approximately <strong className="text-[var(--color-success)]">{fmt(totalRecoverable)} mana</strong>.
|
||||
<div className="mt-2 p-2 bg-[var(--bg-sunken)]/50 rounded text-xs">
|
||||
Equipment: {instance.name}<br />
|
||||
Enchantments to remove: {instance.enchantments.length}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel
|
||||
className="bg-[var(--bg-sunken)] border-[var(--border-default)] text-[var(--text-primary)] hover:bg-[var(--bg-elevated)]"
|
||||
onClick={() => setShowConfirmDialog(false)}
|
||||
>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-[var(--color-danger)] hover:bg-[var(--interactive-danger-hover)] text-white"
|
||||
onClick={confirmPreparation}
|
||||
>
|
||||
Yes, Remove Enchantments & Prepare
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
)}
|
||||
</GameCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnchantmentPreparer.displayName = "EnchantmentPreparer";
|
||||
EnchantmentPreparer.displayName = 'EnchantmentPreparer';
|
||||
|
||||
Reference in New Issue
Block a user