feat: recreate Attunements tab with detailed attunement cards
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m18s
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# Circular Dependencies
|
# Circular Dependencies
|
||||||
Generated: 2026-05-19T12:44:32.003Z
|
Generated: 2026-05-19T13:55:24.489Z
|
||||||
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
Found: 3 circular chain(s) — these MUST be fixed before modifying involved files.
|
||||||
|
|
||||||
1. Processed 121 files (1.2s) (4 warnings)
|
1. Processed 121 files (1.3s) (4 warnings)
|
||||||
2. 1) data/equipment/index.ts > data/equipment/utils.ts
|
2. 1) data/equipment/index.ts > data/equipment/utils.ts
|
||||||
3. 2) data/golems/index.ts > data/golems/utils.ts
|
3. 2) data/golems/index.ts > data/golems/utils.ts
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"_meta": {
|
"_meta": {
|
||||||
"generated": "2026-05-19T12:44:30.633Z",
|
"generated": "2026-05-19T13:55:23.066Z",
|
||||||
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
|
||||||
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ Mana-Loop/
|
|||||||
│ │ │ │ │ └── StudyStatsSection.tsx
|
│ │ │ │ │ └── StudyStatsSection.tsx
|
||||||
│ │ │ │ ├── AchievementsTab.tsx
|
│ │ │ │ ├── AchievementsTab.tsx
|
||||||
│ │ │ │ ├── ActivityLog.tsx
|
│ │ │ │ ├── ActivityLog.tsx
|
||||||
|
│ │ │ │ ├── AttunementsTab.test.ts
|
||||||
|
│ │ │ │ ├── AttunementsTab.tsx
|
||||||
│ │ │ │ ├── DebugTab.test.ts
|
│ │ │ │ ├── DebugTab.test.ts
|
||||||
│ │ │ │ ├── DebugTab.tsx
|
│ │ │ │ ├── DebugTab.tsx
|
||||||
│ │ │ │ ├── DisciplinesTab.tsx
|
│ │ │ │ ├── DisciplinesTab.tsx
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const SpellsTab = lazy(() => import('@/components/game/tabs').then(module => ({
|
|||||||
const StatsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.StatsTab })));
|
const StatsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.StatsTab })));
|
||||||
const DebugTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.DebugTab })));
|
const DebugTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.DebugTab })));
|
||||||
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab })));
|
const AchievementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AchievementsTab })));
|
||||||
|
const AttunementsTab = lazy(() => import('@/components/game/tabs').then(module => ({ default: module.AttunementsTab })));
|
||||||
|
|
||||||
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
const TabLoadingFallback = () => <div className="p-4 text-center text-gray-400">Loading...</div>;
|
||||||
|
|
||||||
@@ -236,6 +237,7 @@ export default function ManaLoopGame() {
|
|||||||
<TabsTrigger value="disciplines" className="text-xs px-2 py-1">📚 Disciplines</TabsTrigger>
|
<TabsTrigger value="disciplines" className="text-xs px-2 py-1">📚 Disciplines</TabsTrigger>
|
||||||
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
|
<TabsTrigger value="grimoire" className="text-xs px-2 py-1">📖 Grimoire</TabsTrigger>
|
||||||
<TabsTrigger value="debug" className="text-xs px-2 py-1">🐛 Debug</TabsTrigger>
|
<TabsTrigger value="debug" className="text-xs px-2 py-1">🐛 Debug</TabsTrigger>
|
||||||
|
<TabsTrigger value="attunements" className="text-xs px-2 py-1">⚗️ Attunements</TabsTrigger>
|
||||||
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
|
<TabsTrigger value="achievements" className="text-xs px-2 py-1">🏆 Achievements</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
@@ -275,6 +277,14 @@ export default function ManaLoopGame() {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="attunements">
|
||||||
|
<ErrorBoundary fallback={<div className="p-4 text-red-400">attunements tab failed to load.</div>}>
|
||||||
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
<AttunementsTab />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="achievements">
|
<TabsContent value="achievements">
|
||||||
<ErrorBoundary fallback={<div className="p-4 text-red-400">achievements tab failed to load.</div>}>
|
<ErrorBoundary fallback={<div className="p-4 text-red-400">achievements tab failed to load.</div>}>
|
||||||
<Suspense fallback={<TabLoadingFallback />}>
|
<Suspense fallback={<TabLoadingFallback />}>
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
// ─── Test: AttunementsTab barrel export ───────────────────────────────────────
|
||||||
|
|
||||||
|
describe('AttunementsTab module structure', () => {
|
||||||
|
it('exports AttunementsTab from barrel index', async () => {
|
||||||
|
const mod = await import('./AttunementsTab');
|
||||||
|
expect(mod.AttunementsTab).toBeDefined();
|
||||||
|
expect(typeof mod.AttunementsTab).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('AttunementsTab has correct displayName', async () => {
|
||||||
|
const { AttunementsTab } = await import('./AttunementsTab');
|
||||||
|
expect(AttunementsTab.displayName).toBe('AttunementsTab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Barrel export includes AttunementsTab ──────────────────────────────
|
||||||
|
|
||||||
|
describe('Tab barrel export', () => {
|
||||||
|
it('includes AttunementsTab in the tabs index', async () => {
|
||||||
|
const mod = await import('@/components/game/tabs');
|
||||||
|
expect(mod.AttunementsTab).toBeDefined();
|
||||||
|
expect(typeof mod.AttunementsTab).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Attunement data integrity ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Attunement data', () => {
|
||||||
|
it('all attunements have required fields', async () => {
|
||||||
|
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||||
|
for (const [id, def] of Object.entries(ATTUNEMENTS_DEF)) {
|
||||||
|
expect(def.id).toBe(id);
|
||||||
|
expect(def.name).toBeTruthy();
|
||||||
|
expect(def.desc).toBeTruthy();
|
||||||
|
expect(def.slot).toBeTruthy();
|
||||||
|
expect(def.icon).toBeTruthy();
|
||||||
|
expect(def.color).toBeTruthy();
|
||||||
|
expect(def.rawManaRegen).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(def.conversionRate).toBeGreaterThanOrEqual(0);
|
||||||
|
expect(def.capabilities.length).toBeGreaterThan(0);
|
||||||
|
expect(def.skillCategories.length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enchanter is unlocked by default', async () => {
|
||||||
|
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||||
|
expect(ATTUNEMENTS_DEF.enchanter.unlocked).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invoker and fabricator are locked by default', async () => {
|
||||||
|
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||||
|
expect(ATTUNEMENTS_DEF.invoker.unlocked).toBe(false);
|
||||||
|
expect(ATTUNEMENTS_DEF.fabricator.unlocked).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('each attunement has a unique slot', async () => {
|
||||||
|
const { ATTUNEMENTS_DEF } = await import('@/lib/game/data/attunements');
|
||||||
|
const slots = Object.values(ATTUNEMENTS_DEF).map((d) => d.slot);
|
||||||
|
const uniqueSlots = new Set(slots);
|
||||||
|
expect(uniqueSlots.size).toBe(slots.length);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: XP curve ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Attunement XP curve', () => {
|
||||||
|
it('level 1 requires 0 XP', async () => {
|
||||||
|
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
||||||
|
expect(getAttunementXPForLevel(1)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('level 2 requires 1000 XP', async () => {
|
||||||
|
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
||||||
|
expect(getAttunementXPForLevel(2)).toBe(1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('XP requirements increase with level', async () => {
|
||||||
|
const { getAttunementXPForLevel } = await import('@/lib/game/data/attunements');
|
||||||
|
const xp2 = getAttunementXPForLevel(2);
|
||||||
|
const xp3 = getAttunementXPForLevel(3);
|
||||||
|
const xp4 = getAttunementXPForLevel(4);
|
||||||
|
expect(xp3).toBeGreaterThan(xp2);
|
||||||
|
expect(xp4).toBeGreaterThan(xp3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('MAX_ATTUNEMENT_LEVEL is 10', async () => {
|
||||||
|
const { MAX_ATTUNEMENT_LEVEL } = await import('@/lib/game/data/attunements');
|
||||||
|
expect(MAX_ATTUNEMENT_LEVEL).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Attunement store interactions ──────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Attunement store interactions', () => {
|
||||||
|
it('addAttunementXP is callable', async () => {
|
||||||
|
const mockAddXP = await vi.fn();
|
||||||
|
mockAddXP('enchanter', 100);
|
||||||
|
expect(mockAddXP).toHaveBeenCalledWith('enchanter', 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('debugUnlockAttunement is callable', async () => {
|
||||||
|
const mockUnlock = await vi.fn();
|
||||||
|
mockUnlock('invoker');
|
||||||
|
expect(mockUnlock).toHaveBeenCalledWith('invoker');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('setAttunements is callable', async () => {
|
||||||
|
const mockSet = await vi.fn();
|
||||||
|
mockSet({ enchanter: { id: 'enchanter', active: true, level: 1, experience: 0 } });
|
||||||
|
expect(mockSet).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resetAttunements is callable', async () => {
|
||||||
|
const mockReset = await vi.fn();
|
||||||
|
mockReset();
|
||||||
|
expect(mockReset).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: Slot name mapping ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('Attunement slot names', () => {
|
||||||
|
it('all slots used by attunements have display names', async () => {
|
||||||
|
const { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES } = await import('@/lib/game/data/attunements');
|
||||||
|
for (const def of Object.values(ATTUNEMENTS_DEF)) {
|
||||||
|
expect(ATTUNEMENT_SLOT_NAMES[def.slot]).toBeDefined();
|
||||||
|
expect(ATTUNEMENT_SLOT_NAMES[def.slot].length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Test: File size limit ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('File size limits (400 lines max)', () => {
|
||||||
|
it('AttunementsTab.tsx is under 400 lines', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const filePath = path.join(__dirname, 'AttunementsTab.tsx');
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8');
|
||||||
|
const lines = content.split('\n').length;
|
||||||
|
expect(lines).toBeLessThan(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useAttunementStore } from '@/lib/game/stores';
|
||||||
|
import { ATTUNEMENTS_DEF, ATTUNEMENT_SLOT_NAMES, getAttunementXPForLevel, MAX_ATTUNEMENT_LEVEL } from '@/lib/game/data/attunements';
|
||||||
|
import type { AttunementDef, AttunementState } from '@/lib/game/types';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { SectionHeader } from '@/components/ui/section-header';
|
||||||
|
import { DebugName } from '@/components/game/debug/debug-context';
|
||||||
|
import { fmt } from '@/lib/game/stores';
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function getXpForNextLevel(level: number): number {
|
||||||
|
if (level >= MAX_ATTUNEMENT_LEVEL) return 0;
|
||||||
|
return getAttunementXPForLevel(level + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getXpProgress(state: AttunementState): number {
|
||||||
|
const nextXp = getXpForNextLevel(state.level);
|
||||||
|
if (nextXp <= 0) return 100;
|
||||||
|
return Math.min(100, Math.round((state.experience / nextXp) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAttunementUnlocked(id: string, attunements: Record<string, AttunementState>): boolean {
|
||||||
|
return id in attunements;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Attunement Card ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface AttunementCardProps {
|
||||||
|
def: AttunementDef;
|
||||||
|
state?: AttunementState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AttunementCard({ def, state }: AttunementCardProps) {
|
||||||
|
const unlocked = !!state;
|
||||||
|
const xpProgress = state ? getXpProgress(state) : 0;
|
||||||
|
const nextXp = state ? getXpForNextLevel(state.level) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={`bg-gray-900/60 ${unlocked ? 'border-gray-700' : 'border-gray-800 opacity-60'}`}>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xl">{def.icon}</span>
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium text-gray-100">{def.name}</h3>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{ATTUNEMENT_SLOT_NAMES[def.slot] ?? def.slot}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{unlocked ? (
|
||||||
|
<Badge className="bg-teal-900/50 text-teal-300 text-xs">
|
||||||
|
Lv.{state.level}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="border-gray-700 text-gray-500 text-xs">
|
||||||
|
Locked
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
<p className="text-xs text-gray-400 leading-relaxed">{def.desc}</p>
|
||||||
|
|
||||||
|
{/* XP Progress (unlocked only) */}
|
||||||
|
{unlocked && state && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-500">XP Progress</span>
|
||||||
|
<span className="text-gray-400 font-mono">
|
||||||
|
{fmt(state.experience)} / {fmt(nextXp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={xpProgress} className="h-2" />
|
||||||
|
{state.level >= MAX_ATTUNEMENT_LEVEL && (
|
||||||
|
<p className="text-xs text-amber-400 italic">Maximum level reached</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Unlock condition (locked only) */}
|
||||||
|
{!unlocked && def.unlockCondition && (
|
||||||
|
<div className="text-xs text-gray-500 italic border-t border-gray-800 pt-2">
|
||||||
|
🔒 {def.unlockCondition}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Details grid */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs border-t border-gray-800 pt-3">
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Mana Type</span>
|
||||||
|
<p className="text-gray-300 capitalize">
|
||||||
|
{def.primaryManaType ?? 'None (pact-based)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Raw Regen</span>
|
||||||
|
<p className="text-gray-300">+{def.rawManaRegen}/hr</p>
|
||||||
|
</div>
|
||||||
|
{def.conversionRate > 0 && (
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Conversion</span>
|
||||||
|
<p className="text-gray-300">{def.conversionRate}/hr</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<span className="text-gray-500">Status</span>
|
||||||
|
<p className={state?.active ? 'text-green-400' : 'text-gray-500'}>
|
||||||
|
{state?.active ? 'Active' : unlocked ? 'Inactive' : 'Locked'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Capabilities */}
|
||||||
|
<div className="border-t border-gray-800 pt-3">
|
||||||
|
<span className="text-xs text-gray-500 block mb-1.5">Capabilities</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{def.capabilities.map((cap) => (
|
||||||
|
<Badge
|
||||||
|
key={cap}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-400 text-[10px]"
|
||||||
|
>
|
||||||
|
{cap}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skill Categories */}
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 block mb-1.5">Skill Categories</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{def.skillCategories.map((cat) => (
|
||||||
|
<Badge
|
||||||
|
key={cat}
|
||||||
|
variant="outline"
|
||||||
|
className="border-gray-700 text-gray-400 text-[10px]"
|
||||||
|
>
|
||||||
|
{cat}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main Component ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function AttunementsTab() {
|
||||||
|
const attunements = useAttunementStore((s) => s.attunements);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const allDefs = Object.values(ATTUNEMENTS_DEF);
|
||||||
|
const unlockedCount = allDefs.filter((d) => isAttunementUnlocked(d.id, attunements)).length;
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center p-8 text-gray-500">
|
||||||
|
Loading attunements…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DebugName name="AttunementsTab">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Summary header */}
|
||||||
|
<Card className="bg-gray-900/60 border-gray-700">
|
||||||
|
<CardContent className="py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold text-gray-100">Attunements</h2>
|
||||||
|
<p className="text-sm text-gray-400">
|
||||||
|
Class-like abilities tied to body locations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-2xl font-bold text-teal-400">
|
||||||
|
{unlockedCount}
|
||||||
|
<span className="text-sm text-gray-500 font-normal">
|
||||||
|
/{allDefs.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Unlocked</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Attunement cards */}
|
||||||
|
<ScrollArea className="h-[600px] pr-2">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{allDefs.map((def) => (
|
||||||
|
<AttunementCard
|
||||||
|
key={def.id}
|
||||||
|
def={def}
|
||||||
|
state={attunements[def.id]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
</DebugName>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
AttunementsTab.displayName = 'AttunementsTab';
|
||||||
@@ -6,3 +6,4 @@ export { SpellsTab } from './SpellsTab';
|
|||||||
export { StatsTab } from './StatsTab';
|
export { StatsTab } from './StatsTab';
|
||||||
export { DebugTab } from './DebugTab';
|
export { DebugTab } from './DebugTab';
|
||||||
export { AchievementsTab } from './AchievementsTab';
|
export { AchievementsTab } from './AchievementsTab';
|
||||||
|
export { AttunementsTab } from './AttunementsTab';
|
||||||
|
|||||||
Reference in New Issue
Block a user