diff --git a/.sisyphus/notepads/dm-dashboard-ai/learnings.md b/.sisyphus/notepads/dm-dashboard-ai/learnings.md index fe6b0f3..69fbc0a 100644 --- a/.sisyphus/notepads/dm-dashboard-ai/learnings.md +++ b/.sisyphus/notepads/dm-dashboard-ai/learnings.md @@ -172,8 +172,8 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet }; }); ``` - - The mock DB then implements a simple `evaluateCondition()` recursive evaluator that checks `type === "eq"` against item properties (converting snake_case column names to camelCase). - - This allows the mock to correctly filter by `userId` and `id`, making cross-user access tests reliable. + - The mock DB then implements a simple `evaluateCondition()` recursive evaluator that checks `type === "eq"` against item properties (converting snake_case column names to camelCase). + - This allows the mock to correctly filter by `userId` and `id`, making cross-user access tests reliable. 6. **Test coverage** - `tests/notes-api.test.ts`: 19 tests covering auth 401s, CORS OPTIONS, GET list, POST create, POST validation errors, PUT update, PUT 404 (missing + cross-user), DELETE remove, DELETE 404 (missing + cross-user), missing/invalid id params. @@ -382,3 +382,332 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet - `npx tsc --noEmit`: 0 errors in project code (1 pre-existing parse error in `tests/history-api.test.ts`) - `npm run build`: fails due to pre-existing auth env validation at build-time (`JWT_SECRET` missing) — unrelated to this change +--- + +# NPC Result Card Component — Planning Findings + +## Date: 2026-05-15 + +### Existing Patterns Analyzed + +**DmCard.astro** (`src/components/dm/DmCard.astro`): +- Base classes: `rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 hover:border-[var(--border-gold)] transition-all duration-[var(--transition-base)]` +- Padding variants: `none` (""), `sm` (p-4), `md` (p-5), `lg` (p-6) +- Props: `class?: string`, `padding?: "none" | "sm" | "md" | "lg"`, `dataTestid?: string` +- Use `padding="lg"` for the NPC card to match the spacious feel of DM cards. + +**DmButton.astro** (`src/components/dm/DmButton.astro`): +- Variants: `primary` (accent bg, white text), `secondary` (card bg + border), `ghost` +- Sizes: `sm` (px-4 py-1.5 text-sm rounded-lg), `md` (px-6 py-2.5 text-base rounded-xl), `lg` +- For action buttons, use `variant="secondary" size="sm"` for "Copy JSON" and `variant="primary" size="sm"` for "Regenerate". + +**DM Theme CSS (`src/styles/dm-theme.css`)**: +- Backgrounds: `--bg-primary: #16120e`, `--bg-card: #221e18`, `--bg-secondary: #1e1912` +- Text: `--text-primary: #f4f4f5`, `--text-secondary: #a1a1aa`, `--text-muted: #71717a`, `--text-cream: #e8dcc8` +- Borders: `--border-color: #3a3428`, `--border-gold: rgba(200,168,75,0.15)`, `--border-gold-strong: rgba(180,150,80,0.1)` +- Gold accents: `--gold: #c8a84b`, `--gold-light: #d4b76a`, `--gold-dark: #a88a3a` + +**⚠️ CRITICAL: Theme Discrepancy — Purple vs Orange** +- `src/styles/dm-theme.css` sets `--accent: #534AB7` (purple), but AGENTS.md claims DM overrides to `#E87722` (orange). +- The task mandates: "Orange theme (#E87722), NO purple". +- **Recommendation**: Update `dm-theme.css` line 3 from `--accent: #534AB7` to `--accent: #E87722` and update `--accent-light`, `--accent-dark`, `--accent-hover` accordingly. This fixes the theme for ALL DM components at once (DmButton primary, focus rings, etc.). Then the NPC card can safely use `var(--accent)`. +- If modifying the theme CSS is out of scope, hardcode `#E87722` in the NPC card for all accent usage (stat values, story border, tags, buttons). + +### NPCResult Schema (`src/lib/ai/types.ts`) + +Available fields: +- `name: string`, `race: string`, `role: string`, `level: number` +- `hp: number`, `ac: number`, `cr: string`, `speed: string` +- `appearance: string`, `trait: string`, `motivation: string`, `secret: string`, `history: string` + +**Missing fields for tags**: There is **no `tags`, `skills`, or `abilities` array** in `NPCResult`. The requirement asks for "Tags: skills, abilities (pill chips)". +- **Decision**: Derive pseudo-tags from `race` and `role` (display both as chips), OR omit the tags section entirely since the data model doesn't support it. Recommending the pseudo-tag approach for visual completeness. + +### Sanitization Strategy + +**Astro auto-escapes server-rendered expressions** — `{npc.name}`, `{npc.appearance}`, etc. are safe by default. No manual escaping needed in the `.astro` template. + +**No `innerHTML` usage**: The card can be fully static markup. Interactivity (copy JSON, regenerate) uses `addEventListener` on existing DOM nodes. Copy JSON does `navigator.clipboard.writeText(JSON.stringify(npc))`. Regenerate calls the passed callback. + +**No shared `escapeHtml` utility** exists in `src/lib/client/`. It is duplicated in 3 files. For this component, since it's pure Astro template with no dynamic HTML injection, `escapeHtml` is unnecessary. But if future iterations add client-side NPC rendering, consolidate `escapeHtml` into `src/lib/client/escape-html.ts`. + +### i18n Gaps + +`src/i18n/dm-translations.ts` has **no NPC-specific labels**. Must add: +```ts +// NPC labels +npcName: "Имя", +npcRace: "Раса", +npcRole: "Класс", +npcLevel: "Уровень", +npcHp: "ХП", +npcAc: "КЗ", +npcCr: "ОП", +npcSpeed: "Скорость", +npcAppearance: "Внешность", +npcTrait: "Черта", +npcMotivation: "Мотивация", +npcSecret: "Тайна", +npcHistory: "История", +npcCopyJson: "Копировать JSON", +npcCopied: "Скопировано", +npcRegenerate: "Перегенерировать", +``` + +### Clipboard Utility + +`src/lib/client/clipboard.ts` exports `CopyFeedback` class for copy → checkmark icon swap animation. Can be used for the "Copy JSON" button if implementing a visual feedback state. + +### Recommended Component Structure + +```astro +--- +import DmCard from "./DmCard.astro"; +import DmButton from "./DmButton.astro"; +import { dmTranslations as T } from "@/i18n/dm-translations"; +import type { NPCResult } from "@/lib/ai/types"; + +export interface Props { + npc: NPCResult; + onRegenerate?: () => void; +} + +const { npc, onRegenerate } = Astro.props; +--- + + + +
+
+

{npc.name}

+

+ {npc.race} · {npc.role} · Ур. {npc.level} · ОП {npc.cr} +

+
+
+ + {T.npcCopyJson} + + {onRegenerate && ( + + {T.npcRegenerate} + + )} +
+
+ + +
+ + + + +
+ + +
+ + + + +
+ + +
+

{npc.history}

+
+ + +
+ + {npc.race} + + + {npc.role} + +
+
+ + +``` + +**Note on data passing to client script**: Astro components cannot directly pass objects to ` diff --git a/src/components/dm/AiNpcResult.astro b/src/components/dm/AiNpcResult.astro new file mode 100644 index 0000000..7c0abd9 --- /dev/null +++ b/src/components/dm/AiNpcResult.astro @@ -0,0 +1,172 @@ +--- +import DmCard from "./DmCard.astro"; +import DmButton from "./DmButton.astro"; +import { dmTranslations as T } from "@/i18n/dm-translations"; +import type { NPCResult } from "@/lib/ai/types"; + +export interface Props { + npc: NPCResult; + onRegenerate?: () => void; +} + +const { npc, onRegenerate } = Astro.props; +--- + + +
+
+

+ {npc.name} +

+

+ {npc.race} · {npc.role} · {T.npcLevel} {npc.level} · {T.npcCr} {npc.cr} +

+
+
+ + {T.npcCopyJson} + + { + onRegenerate && ( + + {T.npcRegenerate} + + ) + } +
+
+ +
+
+
+ {T.npcHp} +
+
{npc.hp}
+
+
+
+ {T.npcAc} +
+
{npc.ac}
+
+
+
+ {T.npcSpeed} +
+
{npc.speed}
+
+
+
+ {T.npcCr} +
+
{npc.cr}
+
+
+ +
+
+
+ {T.npcAppearance} +
+

+ {npc.appearance} +

+
+
+
+ {T.npcTrait} +
+

+ {npc.trait} +

+
+
+
+ {T.npcMotivation} +
+

+ {npc.motivation} +

+
+
+
+ {T.npcSecret} +
+

+ {npc.secret} +

+
+
+ +
+

{npc.history}

+
+ +
+ + {npc.race} + + + {npc.role} + +
+
+ + diff --git a/src/components/dm/GenerationHistory.astro b/src/components/dm/GenerationHistory.astro new file mode 100644 index 0000000..3e67369 --- /dev/null +++ b/src/components/dm/GenerationHistory.astro @@ -0,0 +1,335 @@ +--- +export interface Props { + tier: 'free' | 'pro'; + userId?: number; +} + +import DmCard from './DmCard.astro'; +import { dmTranslations as T } from '@/i18n/dm-translations'; + +const { tier, userId } = Astro.props; +--- + +
+ +
+

+ {T.historyTitle} +

+ + 0 + +
+ + + + + + + + +
+
+ + diff --git a/src/components/dm/InitiativeTracker.astro b/src/components/dm/InitiativeTracker.astro index 8fcb7f5..1d978e0 100644 --- a/src/components/dm/InitiativeTracker.astro +++ b/src/components/dm/InitiativeTracker.astro @@ -1,11 +1,59 @@ --- +export interface Props { + tier?: 'free' | 'pro'; + userId?: number; +} + import DmButton from "./DmButton.astro"; import DmInput from "./DmInput.astro"; import DmCard from "./DmCard.astro"; import { dmTranslations as T } from "@/i18n/dm-translations"; + +const { tier = 'free', userId } = Astro.props; --- -
+
+ + {tier === 'pro' && ( +
+ +
+
+ +
+ + {T.saveSession} + + + {T.newSession} + +
+
+
+
+
+ )} +
@@ -106,6 +154,7 @@ import { dmTranslations as T } from "@/i18n/dm-translations"; const STORAGE_KEY = "dm-initiative"; // DOM refs + const trackerEl = document.getElementById("initiative-tracker"); const nameInput = document.getElementById("it-name") as HTMLInputElement; const modifierInput = document.getElementById( "it-modifier", @@ -122,7 +171,20 @@ import { dmTranslations as T } from "@/i18n/dm-translations"; const emptyState = document.getElementById("it-empty"); const announcer = document.getElementById("it-announcer"); + // PRO refs + const sessionNameInput = document.getElementById("it-session-name") as HTMLInputElement | null; + const saveSessionBtn = document.getElementById("it-save-session-btn"); + const newSessionBtn = document.getElementById("it-new-session-btn"); + const sessionStatusEl = document.getElementById("it-session-status"); + const sessionListEl = document.getElementById("it-session-list"); + + const tier = (trackerEl?.dataset.tier as 'free' | 'pro') || 'free'; + const userId = trackerEl?.dataset.userId ? parseInt(trackerEl.dataset.userId, 10) : undefined; + const isPro = tier === 'pro' && !!userId; + let activeIndex = 0; + let currentSessionId: number | null = null; + let currentSessionName = ''; interface CombatantData { id: string; @@ -392,6 +454,163 @@ import { dmTranslations as T } from "@/i18n/dm-translations"; initiativeInput.value = String(result); } + // --- PRO Session Management --- + + interface DbSession { + id: number; + name: string; + participants: CombatantData[]; + createdAt: string; + updatedAt: string; + } + + async function apiFetchSessions(): Promise { + const res = await fetch('/api/dm/initiative'); + if (!res.ok) return []; + return res.json() as Promise; + } + + async function apiSaveSession(name: string, participants: CombatantData[], id?: number): Promise { + if (id) { + const res = await fetch(`/api/dm/initiative?id=${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, participants }), + }); + if (!res.ok) return null; + return res.json() as Promise; + } else { + const res = await fetch('/api/dm/initiative', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, participants }), + }); + if (!res.ok) return null; + return res.json() as Promise; + } + } + + async function apiDeleteSession(id: number): Promise { + const res = await fetch(`/api/dm/initiative?id=${id}`, { method: 'DELETE' }); + return res.ok; + } + + function showSessionStatus(message: string, isError = false) { + if (!sessionStatusEl) return; + sessionStatusEl.textContent = message; + sessionStatusEl.className = `text-sm min-h-[1.25rem] ${isError ? 'text-[var(--danger)]' : 'text-[var(--text-secondary)]'}`; + setTimeout(() => { + if (sessionStatusEl) { + sessionStatusEl.textContent = ''; + sessionStatusEl.className = 'text-sm min-h-[1.25rem] text-[var(--text-secondary)]'; + } + }, 3000); + } + + async function renderSessionList() { + if (!sessionListEl) return; + const sessions = await apiFetchSessions(); + + if (sessions.length === 0) { + sessionListEl.innerHTML = `

Нет сохранённых сессий

`; + return; + } + + sessionListEl.innerHTML = sessions.map((s) => { + const date = new Date(s.updatedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' }); + const isActive = s.id === currentSessionId; + return ` +
+ + +
+ `; + }).join(''); + + sessionListEl.querySelectorAll('.it-load-session').forEach((btn) => { + btn.addEventListener('click', () => { + const id = (btn as HTMLElement).dataset.id; + if (id) loadDbSession(parseInt(id, 10)); + }); + }); + + sessionListEl.querySelectorAll('.it-delete-session').forEach((btn) => { + btn.addEventListener('click', () => { + const id = (btn as HTMLElement).dataset.id; + if (id) deleteDbSession(parseInt(id, 10)); + }); + }); + } + + async function loadDbSession(id: number) { + try { + const sessions = await apiFetchSessions(); + const session = sessions.find((s) => s.id === id); + if (!session) { + showSessionStatus('Ошибка загрузки сессии', true); + return; + } + saveCombatants(session.participants); + activeIndex = 0; + currentSessionId = session.id; + currentSessionName = session.name; + if (sessionNameInput) sessionNameInput.value = session.name; + updateVisibility(); + renderList(); + renderSessionList(); + showSessionStatus('Сессия загружена'); + } catch { + showSessionStatus('Ошибка загрузки сессии', true); + } + } + + async function handleSaveSession() { + if (!isPro) return; + const name = sessionNameInput?.value.trim() || `Сессия ${new Date().toLocaleDateString('ru-RU')}`; + const participants = loadCombatants(); + + const result = await apiSaveSession(name, participants, currentSessionId ?? undefined); + if (result) { + currentSessionId = result.id; + currentSessionName = result.name; + if (sessionNameInput) sessionNameInput.value = result.name; + showSessionStatus('Сессия сохранена'); + renderSessionList(); + } else { + showSessionStatus('Ошибка сохранения', true); + } + } + + async function handleNewSession() { + currentSessionId = null; + currentSessionName = ''; + if (sessionNameInput) sessionNameInput.value = ''; + clearAll(); + showSessionStatus('Новая сессия'); + renderSessionList(); + } + + async function deleteDbSession(id: number) { + if (!confirm('Удалить сессию?')) return; + const ok = await apiDeleteSession(id); + if (ok) { + if (currentSessionId === id) { + currentSessionId = null; + currentSessionName = ''; + if (sessionNameInput) sessionNameInput.value = ''; + } + renderSessionList(); + } else { + showSessionStatus('Ошибка удаления', true); + } + } + // Event listeners rollBtn?.addEventListener("click", handleRoll); addBtn?.addEventListener("click", addCombatant); @@ -412,7 +631,11 @@ import { dmTranslations as T } from "@/i18n/dm-translations"; } }); + saveSessionBtn?.addEventListener('click', handleSaveSession); + newSessionBtn?.addEventListener('click', handleNewSession); + // Init updateVisibility(); renderList(); + if (isPro) renderSessionList(); diff --git a/src/components/dm/NotesPanel.astro b/src/components/dm/NotesPanel.astro index 79cfdee..9f1379e 100644 --- a/src/components/dm/NotesPanel.astro +++ b/src/components/dm/NotesPanel.astro @@ -1,10 +1,17 @@ --- +export interface Props { + tier?: 'free' | 'pro'; + userId?: number; +} + import DmCard from './DmCard.astro'; import DmButton from './DmButton.astro'; import { dmTranslations as T } from '../../i18n/dm-translations'; + +const { tier = 'free', userId } = Astro.props; --- - +