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,30 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { GameCard } from '@/components/ui/game-card';
|
||||
import { SectionHeader } from '@/components/ui/section-header';
|
||||
import { StatRow } from '@/components/ui/stat-row';
|
||||
import { ActionButton } from '@/components/ui/action-button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Wand2, Scroll, Trash2, Plus, Minus } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { AlertCircle, Wand2, Scroll, Trash2, Plus, Minus, Check } from 'lucide-react';
|
||||
import { EQUIPMENT_TYPES } from '@/lib/game/data/equipment';
|
||||
import { ENCHANTMENT_EFFECTS, calculateEffectCapacityCost } from '@/lib/game/data/enchantment-effects';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress } from '@/lib/game/types';
|
||||
import type { EquipmentInstance, EnchantmentDesign, DesignEffect, EquipmentCraftingProgress, EquipmentCategory } 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',
|
||||
};
|
||||
|
||||
export interface EnchantmentDesignerProps {
|
||||
store: GameStore;
|
||||
selectedEquipmentType: string | null;
|
||||
@@ -137,243 +129,321 @@ export function EnchantmentDesigner({
|
||||
);
|
||||
};
|
||||
|
||||
// Get incompatible effects (unlocked but not for this equipment type)
|
||||
// Requirement (task3 bug #7): Show incompatible enchantments in greyed-out "Unavailable" section
|
||||
const getIncompatibleEffects = () => {
|
||||
if (!selectedEquipmentType) return [];
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return [];
|
||||
|
||||
return Object.values(ENCHANTMENT_EFFECTS).filter(
|
||||
effect =>
|
||||
!effect.allowedEquipmentCategories.includes(type.category) &&
|
||||
unlockedEffects.includes(effect.id)
|
||||
);
|
||||
};
|
||||
|
||||
// Get equipment types that the player actually owns (has instances of)
|
||||
// This ensures enchantment compatibility is based on owned items, not just blueprints
|
||||
const getOwnedEquipmentTypes = () => {
|
||||
// Get all unique equipment type IDs from owned instances
|
||||
const ownedEquipmentTypeIds = new Set<string>();
|
||||
|
||||
|
||||
// Check all equipment instances the player owns
|
||||
for (const instance of Object.values(store.equipmentInstances)) {
|
||||
ownedEquipmentTypeIds.add(instance.typeId);
|
||||
}
|
||||
|
||||
|
||||
// Filter EQUIPMENT_TYPES to only include types the player owns
|
||||
return Object.values(EQUIPMENT_TYPES).filter(type => ownedEquipmentTypeIds.has(type.id));
|
||||
};
|
||||
|
||||
const ownedEquipmentTypes = getOwnedEquipmentTypes();
|
||||
const availableEffects = getAvailableEffects();
|
||||
const incompatibleEffects = getIncompatibleEffects();
|
||||
|
||||
// Render design stage
|
||||
// Get the reason why an effect is incompatible
|
||||
const getIncompatibilityReason = (effect: typeof ENCHANTMENT_EFFECTS[string]): string => {
|
||||
if (!selectedEquipmentType) return 'No equipment selected';
|
||||
const type = EQUIPMENT_TYPES[selectedEquipmentType];
|
||||
if (!type) return 'Unknown equipment type';
|
||||
|
||||
// Check what categories this effect is allowed for
|
||||
const allowedCategories = effect.allowedEquipmentCategories;
|
||||
const equipmentCategory = type.category;
|
||||
|
||||
if (allowedCategories.includes(equipmentCategory)) {
|
||||
return 'Compatible';
|
||||
}
|
||||
|
||||
// Provide specific reasons
|
||||
if (allowedCategories.includes('weapon' as EquipmentCategory) && equipmentCategory !== 'sword' && equipmentCategory !== 'caster' && equipmentCategory !== 'catalyst') {
|
||||
return `Requires a weapon (${allowedCategories.filter(c => ['sword', 'caster', 'catalyst'].includes(c)).join(', ')})`;
|
||||
}
|
||||
|
||||
return `Requires ${allowedCategories.join(' or ')} equipment`;
|
||||
};
|
||||
|
||||
// Render stage
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Equipment Type Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">1. Select Equipment Type</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{designProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-gray-400">
|
||||
Designing for: {EQUIPMENT_TYPES[designProgress.equipmentType]?.name}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-amber-300">{designProgress.name}</div>
|
||||
<Progress value={(designProgress.progress / designProgress.required) * 100} className="h-3" />
|
||||
<div className="flex justify-between text-xs text-gray-400">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<Button size="sm" variant="outline" onClick={cancelDesign}>Cancel</Button>
|
||||
</div>
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="1. Select Equipment Type" />
|
||||
{designProgress ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm text-[var(--text-secondary)]">
|
||||
Designing for: {EQUIPMENT_TYPES[designProgress.equipmentType]?.name}
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ownedEquipmentTypes.map(type => (
|
||||
<div
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all ${
|
||||
selectedEquipmentType === type.id
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50 hover:border-gray-600'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentType(type.id)}
|
||||
>
|
||||
<div className="text-sm font-semibold">{type.name}</div>
|
||||
<div className="text-xs text-gray-400">Cap: {type.baseCapacity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{ownedEquipmentTypes.length === 0 && (
|
||||
<div className="text-center text-gray-400 py-4 text-sm">
|
||||
No equipment blueprints owned. Craft or find equipment blueprints first.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Effect Selection */}
|
||||
<Card className="bg-gray-900/80 border-gray-700">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">2. Select Effects</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enchantingLevel < 1 ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Learn Enchanting skill to design enchantments</p>
|
||||
<div className="text-sm font-semibold text-[var(--mana-light)]">{designProgress.name}</div>
|
||||
<Progress
|
||||
value={(designProgress.progress / designProgress.required) * 100}
|
||||
className="h-3 bg-[var(--bg-sunken)]"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-[var(--text-muted)]">
|
||||
<span>{designProgress.progress.toFixed(1)}h / {designProgress.required.toFixed(1)}h</span>
|
||||
<ActionButton size="sm" variant="outline" onClick={cancelDesign}>Cancel</ActionButton>
|
||||
</div>
|
||||
) : designProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-gray-400">Design in progress...</div>
|
||||
{designProgress.effects.map(eff => {
|
||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
return (
|
||||
<div key={eff.effectId} className="flex justify-between text-sm">
|
||||
<span>{def?.name} x{eff.stacks}</span>
|
||||
<span className="text-gray-400">{eff.capacityCost} cap</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !selectedEquipmentType ? (
|
||||
<div className="text-center text-gray-400 py-8">
|
||||
Select an equipment type first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-48 mb-4">
|
||||
<div className="space-y-2">
|
||||
{getAvailableEffects().map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`p-2 rounded border transition-all ${
|
||||
selected
|
||||
? 'border-purple-500 bg-purple-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold">{effect.name}</div>
|
||||
<div className="text-xs text-gray-400">{effect.description}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{selected && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Badge variant="outline" className="mt-1 text-xs">
|
||||
{selected.stacks}/{effect.maxStacks}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Selected effects summary */}
|
||||
<Separator className="bg-gray-700 my-2" />
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Design name..."
|
||||
value={designName}
|
||||
onChange={(e) => setDesignName(e.target.value)}
|
||||
className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-sm"
|
||||
/>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>Total Capacity:</span>
|
||||
<span className={isOverCapacity ? 'text-red-400' : 'text-green-400'}>
|
||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm text-gray-400">
|
||||
<span>Design Time:</span>
|
||||
<span>{designTime.toFixed(1)}h</span>
|
||||
</div>
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||
onClick={handleCreateDesign}
|
||||
>
|
||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Saved Designs */}
|
||||
<Card className="bg-gray-900/80 border-gray-700 lg:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-amber-400 text-sm">Saved Designs ({enchantmentDesigns.length})</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{enchantmentDesigns.length === 0 ? (
|
||||
<div className="text-center text-gray-400 py-4">
|
||||
No saved designs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{enchantmentDesigns.map(design => (
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-64">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{ownedEquipmentTypes.map(type => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-3 rounded border ${
|
||||
selectedDesign === design.id
|
||||
? 'border-amber-500 bg-amber-900/20'
|
||||
: 'border-gray-700 bg-gray-800/50'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
key={type.id}
|
||||
className={`p-2 rounded border cursor-pointer transition-all
|
||||
${selectedEquipmentType === type.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedEquipmentType(type.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select ${type.name}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold">{design.name}</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-gray-400 hover:text-red-400"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteDesign(design.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-400">
|
||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{type.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">Cap: {type.baseCapacity}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{ownedEquipmentTypes.length === 0 && (
|
||||
<div className="text-center text-[var(--text-muted)] py-4 text-sm">
|
||||
No equipment blueprints owned. Craft or find equipment blueprints first.
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
{/* Effect Selection */}
|
||||
<GameCard variant="default">
|
||||
<SectionHeader title="2. Select Effects" />
|
||||
{enchantingLevel < 1 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
<Wand2 className="w-12 h-12 mx-auto mb-2 opacity-50 text-[var(--text-disabled)]" />
|
||||
<p>Learn Enchanting skill to design enchantments</p>
|
||||
</div>
|
||||
) : designProgress ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-[var(--text-secondary)]">Design in progress...</div>
|
||||
{designProgress.effects.map(eff => {
|
||||
const def = ENCHANTMENT_EFFECTS[eff.effectId];
|
||||
return (
|
||||
<div key={eff.effectId} className="flex justify-between text-sm text-[var(--text-primary)]">
|
||||
<span>{def?.name} x{eff.stacks}</span>
|
||||
<span className="text-[var(--text-muted)]">{eff.capacityCost} cap</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : !selectedEquipmentType ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-8">
|
||||
Select an equipment type first
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ScrollArea className="h-48 mb-4">
|
||||
<div className="space-y-2">
|
||||
{/* Compatible Effects */}
|
||||
{availableEffects.map(effect => {
|
||||
const selected = selectedEffects.find(e => e.effectId === effect.id);
|
||||
const cost = calculateEffectCapacityCost(effect.id, (selected?.stacks || 0) + 1, efficiencyBonus);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={effect.id}
|
||||
className={`p-2 rounded border transition-all
|
||||
${selected
|
||||
? 'border-[var(--mana-stellar)] bg-[var(--mana-stellar)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-primary)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">{effect.description}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)] mt-1">
|
||||
Cost: {effect.baseCapacityCost} cap | Max: {effect.maxStacks}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{selected && (
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => removeEffect(effect.id)}
|
||||
>
|
||||
<Minus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
)}
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 w-6 p-0"
|
||||
onClick={() => addEffect(effect.id)}
|
||||
disabled={!selected && selectedEffects.length >= 5}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
</div>
|
||||
{selected && (
|
||||
<Badge variant="outline" className="mt-1 text-xs border-[var(--mana-stellar)] text-[var(--mana-stellar)]">
|
||||
{selected.stacks}/{effect.maxStacks}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Incompatible Effects - Requirement: greyed-out "Unavailable" section with tooltips */}
|
||||
{incompatibleEffects.length > 0 && (
|
||||
<>
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<div className="text-xs font-semibold text-[var(--text-disabled)] uppercase tracking-wider mb-2">
|
||||
Unavailable
|
||||
</div>
|
||||
{incompatibleEffects.map(effect => {
|
||||
const reason = getIncompatibilityReason(effect);
|
||||
|
||||
return (
|
||||
<TooltipProvider key={effect.id}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className="p-2 rounded border border-[var(--border-subtle)] bg-[var(--bg-sunken)]/30 opacity-50 cursor-not-allowed"
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-[var(--text-disabled)]">{effect.name}</div>
|
||||
<div className="text-xs text-[var(--text-disabled)]">{effect.description}</div>
|
||||
</div>
|
||||
<AlertCircle size={14} className="text-[var(--text-disabled)]" />
|
||||
</div>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="bg-[var(--bg-elevated)] border-[var(--border-default)] text-[var(--text-primary)]">
|
||||
<p className="font-semibold">Incompatible Effect</p>
|
||||
<p className="text-xs text-[var(--text-muted)] mt-1">{reason}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Selected effects summary */}
|
||||
<Separator className="bg-[var(--border-subtle)] my-2" />
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Design name..."
|
||||
value={designName}
|
||||
onChange={(e) => setDesignName(e.target.value)}
|
||||
className="w-full bg-[var(--bg-sunken)] border border-[var(--border-default)] rounded px-3 py-2 text-sm text-[var(--text-primary)] placeholder:text-[var(--text-disabled)] focus:outline-none focus:border-[var(--border-focus)]"
|
||||
aria-label="Design name"
|
||||
/>
|
||||
<StatRow
|
||||
label="Total Capacity:"
|
||||
value={
|
||||
<span className={isOverCapacity ? 'text-[var(--color-danger)]' : 'text-[var(--color-success)]'}>
|
||||
{designCapacityCost.toFixed(0)} / {selectedEquipmentCapacity}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<StatRow
|
||||
label="Design Time:"
|
||||
value={`${designTime.toFixed(1)}h`}
|
||||
highlight="default"
|
||||
/>
|
||||
<ActionButton
|
||||
className="w-full"
|
||||
disabled={!designName || selectedEffects.length === 0 || isOverCapacity}
|
||||
onClick={handleCreateDesign}
|
||||
>
|
||||
{isOverCapacity ? 'Over Capacity!' : `Start Design (${designTime.toFixed(1)}h)`}
|
||||
</ActionButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</GameCard>
|
||||
|
||||
{/* Saved Designs */}
|
||||
<GameCard variant="default" className="lg:col-span-2">
|
||||
<SectionHeader title={`Saved Designs (${enchantmentDesigns.length})`} />
|
||||
{enchantmentDesigns.length === 0 ? (
|
||||
<div className="text-center text-[var(--text-muted)] py-4">
|
||||
No saved designs yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{enchantmentDesigns.map(design => (
|
||||
<div
|
||||
key={design.id}
|
||||
className={`p-3 rounded border cursor-pointer transition-all
|
||||
${selectedDesign === design.id
|
||||
? 'border-[var(--mana-light)] bg-[var(--mana-light)]/10'
|
||||
: 'border-[var(--border-default)] bg-[var(--bg-sunken)]/50 hover:border-[var(--border-default)]'
|
||||
}`}
|
||||
onClick={() => setSelectedDesign(design.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Select design: ${design.name}`}
|
||||
>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<div className="font-semibold text-[var(--text-primary)]">{design.name}</div>
|
||||
<div className="text-xs text-[var(--text-muted)]">
|
||||
{EQUIPMENT_TYPES[design.equipmentType]?.name}
|
||||
</div>
|
||||
</div>
|
||||
<ActionButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 p-0 text-[var(--text-muted)] hover:text-[var(--color-danger)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteDesign(design.id);
|
||||
}}
|
||||
aria-label={`Delete design: ${design.name}`}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</ActionButton>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-[var(--text-muted)]">
|
||||
{design.effects.length} effects | {design.totalCapacityUsed} cap
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</GameCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EnchantmentDesigner.displayName = "EnchantmentDesigner";
|
||||
EnchantmentDesigner.displayName = 'EnchantmentDesigner';
|
||||
|
||||
Reference in New Issue
Block a user