feat(dm-dashboard): Wave 6 — NPC form, result card, history panel, notes/initiative DB migration, translation service

This commit is contained in:
emil
2026-05-15 23:09:06 +03:00
parent 75f2dc6972
commit 09e947f430
14 changed files with 1748 additions and 108 deletions
+331 -2
View File
@@ -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;
---
<DmCard padding="lg" dataTestid="ai-npc-result">
<!-- Header: Name + Meta + Actions -->
<div class="flex items-start justify-between gap-4 mb-5">
<div class="min-w-0">
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">{npc.name}</h3>
<p class="text-sm text-[var(--text-secondary)] mt-1">
{npc.race} · {npc.role} · Ур. {npc.level} · ОП {npc.cr}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<DmButton variant="secondary" size="sm" id="npc-copy-btn" dataTestid="npc-copy-btn">
{T.npcCopyJson}
</DmButton>
{onRegenerate && (
<DmButton variant="primary" size="sm" id="npc-regen-btn" dataTestid="npc-regen-btn">
{T.npcRegenerate}
</DmButton>
)}
</div>
</div>
<!-- Stats Row: HP, AC, Speed, CR (4-col grid) -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<StatBox label={T.npcHp} value={String(npc.hp)} />
<StatBox label={T.npcAc} value={String(npc.ac)} />
<StatBox label={T.npcSpeed} value={npc.speed} />
<StatBox label={T.npcCr} value={npc.cr} />
</div>
<!-- Attributes: Appearance, Trait, Motivation, Secret -->
<div class="space-y-3 mb-5">
<AttributeRow label={T.npcAppearance} value={npc.appearance} />
<AttributeRow label={T.npcTrait} value={npc.trait} />
<AttributeRow label={T.npcMotivation} value={npc.motivation} />
<AttributeRow label={T.npcSecret} value={npc.secret} />
</div>
<!-- Story Block: History/Backstory -->
<div class="border-l-4 border-[#E87722] pl-4 py-2 italic text-[var(--text-cream)] bg-[var(--bg-secondary)]/50 rounded-r-lg">
<p class="text-sm leading-relaxed">{npc.history}</p>
</div>
<!-- Tags (pseudo-tags from race + role) -->
<div class="flex flex-wrap gap-2 mt-5">
<span class="px-3 py-1 text-xs font-medium rounded-full bg-[#E87722]/10 text-[#E87722] border border-[#E87722]/20">
{npc.race}
</span>
<span class="px-3 py-1 text-xs font-medium rounded-full bg-[#E87722]/10 text-[#E87722] border border-[#E87722]/20">
{npc.role}
</span>
</div>
</DmCard>
<script>
// Client-side: copy JSON + regenerate handler
document.addEventListener("DOMContentLoaded", () => {
const copyBtn = document.getElementById("npc-copy-btn");
const regenBtn = document.getElementById("npc-regen-btn");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const card = copyBtn.closest('[data-testid="ai-npc-result"]');
const npcData = card?.getAttribute("data-npc");
if (!npcData) return;
try {
await navigator.clipboard.writeText(npcData);
const originalText = copyBtn.textContent;
copyBtn.textContent = "Скопировано";
setTimeout(() => { copyBtn.textContent = originalText; }, 1500);
} catch {
/* ignore clipboard errors */
}
});
}
if (regenBtn) {
regenBtn.addEventListener("click", () => {
regenBtn.dispatchEvent(new CustomEvent("npc-regenerate", { bubbles: true }));
});
}
});
</script>
```
**Note on data passing to client script**: Astro components cannot directly pass objects to `<script>` blocks. Options:
1. Serialize NPC as `data-npc` JSON attribute on the card container (`data-npc={JSON.stringify(npc)}`). The script reads it via `getAttribute` + `JSON.parse`. This is safe since Astro auto-escapes attribute values.
2. Have the parent page manage the copy/regenerate logic and dispatch custom events.
3. Use `is:inline` script with `define:vars` (Astro feature) to pass the npc object directly to the script.
**Recommended**: Use `define:vars` with `is:inline` script for clean data passing without DOM attribute parsing.
### Checklist for Implementation
- [ ] Add NPC i18n keys to `src/i18n/dm-translations.ts`
- [ ] Fix `dm-theme.css` `--accent` to `#E87722` (or hardcode orange in component)
- [ ] Create `src/components/dm/AiNpcResult.astro`
- [ ] Props: `npc: NPCResult`, `onRegenerate?: () => void`
- [ ] Use `DmCard` with `padding="lg"`
- [ ] Header: Name (bold, truncate), meta line (race · role · level · CR), Copy + Regenerate buttons
- [ ] Stats: 4-col grid (HP, AC, Speed, CR) with `bg-[var(--bg-secondary)]` boxes
- [ ] Attributes: 4 rows with label (muted, uppercase) + value
- [ ] Story block: left orange border (`border-l-4 border-[#E87722]`), italic, cream text, subtle bg
- [ ] Tags: pill chips for race + role (or omit if out of scope)
- [ ] Copy JSON: `navigator.clipboard.writeText(JSON.stringify(npc))` with brief "Скопировано" feedback
- [ ] Regenerate: dispatch custom event or call callback
- [ ] Zero `innerHTML` usage — pure Astro template + event listeners
- [ ] No favorite button
- [ ] Add `dataTestid` attributes for testing
---
# InitiativeTracker DB Persistence Migration — Learnings
## Date: 2026-05-15
### Context
Task: Migrate InitiativeTracker to use DB persistence for PRO users, keep sessionStorage for FREE.
### Current State Analysis
1. **`src/components/dm/InitiativeTracker.astro`** — Pure client-side component (418 lines).
- Uses `sessionStorage` key `"dm-initiative"` with legacy `"it-combatants"` fallback migration.
- `activeIndex` is a module-level variable — never persisted to any storage.
- No Astro props accepted. Duplicated inline `escapeHtml()` (pre-existing anti-pattern).
2. **`src/pages/api/dm/initiative.ts`** — Full CRUD API already exists.
- `GET` returns `[{ id, name, participants, createdAt, updatedAt }]` for authenticated user.
- `POST` creates session with `{ name, participants? }`.
- `PUT` updates by `?id=` with `{ name?, participants? }`.
- `DELETE` removes by `?id=`.
- Auth via `locals.user`; CORS via `jsonResponse()` helper.
3. **DB Schema** (`initiative_sessions`): `id`, `userId`, `name`, `participants` (JSONB), `createdAt`, `updatedAt`.
4. **Data model compatibility**: Client `CombatantData = { id, name, initiative, hp? }` maps cleanly into API `participantSchema`. The schema is **not** `.strict()`, so extra fields are stripped and optional fields are tolerated. Sending `{ id, name, initiative, hp }` from client is fully valid.
5. **Parent pages**: `src/pages/dm/index.astro` and `src/pages/ru/dm/index.astro` use `<InitiativeTracker />` with no props. `Astro.locals.user` provides `tier` and `id`.
### Implementation Plan
#### Props
```astro
export interface Props {
tier?: 'free' | 'pro';
userId?: number;
}
```
Parent pages pass: `<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />`
#### Server-to-Client Bridge
Add `data-tier={tier}` and `data-user-id={userId ?? ''}` to root `<div>`. Client script reads:
```ts
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;
```
#### PRO-Only UI Panel (Astro template, conditional `{tier === 'pro'}`)
Rendered above add-combatant form inside a `DmCard`:
- `DmInput id="it-session-name"` — session name input.
- `DmButton id="it-save-session-btn"` — manual save trigger.
- `DmButton id="it-new-session-btn"` — clears current session state.
- `#it-session-list` — scrollable list of saved sessions (name + date + delete button).
- `#it-session-status` — transient success/error message.
#### New Translation Keys Needed
- `saveSession`, `newSession`, `sessionNamePlaceholder`, `noSavedSessions`, `sessionSaved`, `saveError`, `loadSessionError`, `deleteSessionConfirm`.
#### Client Script Additions
**State**:
```ts
let currentSessionId: number | null = null;
let currentSessionName = '';
```
**API wrappers**:
- `apiFetchSessions()` → GET /api/dm/initiative
- `apiSaveSession(name, participants, id?)` → POST (new) or PUT (update existing)
- `apiDeleteSession(id)` → DELETE /api/dm/initiative?id=X
**Core functions**:
- `renderSessionList()` — async fetch, renders rows with click-to-load and delete. Highlights current session.
- `loadDbSession(id)` — fetches sessions, maps `participants` → `CombatantData[]`, calls `saveCombatants()` to sessionStorage (graceful fallback), resets `activeIndex = 0`, re-renders.
- `handleSaveSession()` — reads name input (defaults to timestamp), POST or PUT, updates `currentSessionId`, shows status, refreshes list. On error: shows red status, **does not touch sessionStorage**.
- `handleNewSession()` — clears `currentSessionId/name/input`, calls `clearAll()`.
- `deleteDbSession(id)` — confirm dialog, DELETE, re-renders list.
**Event listeners** (attached defensively with `?.`):
```ts
document.getElementById('it-save-session-btn')?.addEventListener('click', handleSaveSession);
document.getElementById('it-new-session-btn')?.addEventListener('click', handleNewSession);
```
**Init**:
```ts
updateVisibility();
renderList();
if (isPro) renderSessionList();
```
### Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Manual save only** | Requirement explicitly forbids auto-save. |
| **sessionStorage always kept** | `saveCombatants()` still writes to `dm-initiative`. On API failure, user loses nothing. On PRO save, sessionStorage is **not** deleted. |
| **activeIndex never in API payload** | It stays a pure in-memory var. Loading a DB session always resets it to 0. |
| **Data attributes for props** | Cleanest bridge for a module `<script>` without inlining vars via `define:vars`. |
| **Zod compatibility** | API schema is not `.strict()`. Client sends minimal payload; server accepts silently. |
| **Session name required** | API `createSessionSchema` requires `name`. Client defaults to `"Сессия {localeDate}"` if empty. |
| **Escape user content** | Session names rendered via existing `escapeHtml()` to prevent XSS in `innerHTML`. |
| **Missing userId fallback** | If `tier === 'pro'` but `userId` is missing, `isPro = false` and behavior falls back to FREE. |
### Files to Modify
1. `src/components/dm/InitiativeTracker.astro` — add props, conditional PRO UI, API integration, session management.
2. `src/i18n/dm-translations.ts` — add 8 new keys.
3. `src/pages/dm/index.astro` — pass `tier` and `userId` props.
4. `src/pages/ru/dm/index.astro` — mirror prop changes.
### Verification Steps
- `npx tsc --noEmit` — zero errors.
- `npx vitest run` — full suite passes (271+ tests).
- Manual QA:
- FREE user: sessionStorage works exactly as before.
- PRO user: add combatants → Save → reload → Load → combatants restored.
- PRO user: `activeIndex` resets to 0 on every session load.
- PRO user: offline/API failure → error shown, sessionStorage intact.
### Pre-existing Bug Note
`src/pages/dm/index.astro` contains a duplicated AI section (lines 80–98 mirror 59–77). Out of scope for this task but should be cleaned up separately.
+6 -6
View File
@@ -1198,7 +1198,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add AI tab to DM navigation`
- Files: `src/components/dm/DmTabs.astro`, `src/components/dm/DmSidebar.astro`, `src/pages/dm/index.astro`, `src/i18n/dm-translations.ts`
- [ ] **16. Build NPC Generator Form Component**
- [x] **16. Build NPC Generator Form Component**
**What to do**:
- Create `src/components/dm/AiNpcForm.astro`: Form for NPC generation parameters.
@@ -1274,7 +1274,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add NPC generator form component`
- Files: `src/components/dm/AiNpcForm.astro`
- [ ] **17. Build NPC Result Card Component**
- [x] **17. Build NPC Result Card Component**
**What to do**:
- Create `src/components/dm/AiNpcResult.astro`: Rich card displaying generated NPC.
@@ -1349,7 +1349,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add NPC result card component`
- Files: `src/components/dm/AiNpcResult.astro`
- [ ] **18. Build Generation History Panel Component**
- [x] **18. Build Generation History Panel Component**
**What to do**:
- Create `src/components/dm/GenerationHistory.astro`: Right-panel list of generated NPCs.
@@ -1468,7 +1468,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add quota and tier badge components`
- Files: `src/components/dm/QuotaBadge.astro`, `src/components/dm/TierBadge.astro`, `src/components/dm/AuthPanel.astro`
- [ ] **20. Migrate Notes to DB for PRO + localStorage Fallback**
- [x] **20. Migrate Notes to DB for PRO + localStorage Fallback**
**What to do**:
- Update `src/components/dm/NotesPanel.astro`: Add tier-aware persistence.
@@ -1538,7 +1538,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(pro): add notes DB persistence for PRO users`
- Files: `src/components/dm/NotesPanel.astro`, `src/lib/client/notes.ts`
- [ ] **21. Migrate Initiative to DB for PRO + sessionStorage Fallback**
- [x] **21. Migrate Initiative to DB for PRO + sessionStorage Fallback**
**What to do**:
- Update `src/components/dm/InitiativeTracker.astro`: Add tier-aware persistence.
@@ -1726,7 +1726,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add translate button to Open5e reference cards`
- Files: `src/components/dm/Open5eReference.astro`
- [ ] **24. Wire Translation Service to UI**
- [x] **24. Wire Translation Service to UI**
**What to do**:
- Create `src/lib/client/translation.ts`: Client-side translation cache and API caller.
+218
View File
@@ -0,0 +1,218 @@
---
import DmCard from "./DmCard.astro";
import DmButton from "./DmButton.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
export interface Props {
disabled?: boolean;
}
const { disabled = false } = Astro.props;
const selectClasses =
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] appearance-none cursor-pointer";
const labelClasses = "block text-sm font-medium text-[var(--text-secondary)] mb-1";
---
<DmCard dataTestid="ai-npc-form-card">
<form
id="ai-npc-form"
class="space-y-4"
data-testid="ai-npc-form"
>
<!-- Race -->
<div>
<label for="npc-race" class={labelClasses}>
{T.aiFormRace}
</label>
<select id="npc-race" name="race" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Human">{T.raceHuman}</option>
<option value="Elf">{T.raceElf}</option>
<option value="Dwarf">{T.raceDwarf}</option>
<option value="Halfling">{T.raceHalfling}</option>
<option value="Orc">{T.raceOrc}</option>
<option value="Tiefling">{T.raceTiefling}</option>
<option value="Dragonborn">{T.raceDragonborn}</option>
<option value="Gnome">{T.raceGnome}</option>
<option value="Half-Elf">{T.raceHalfElf}</option>
<option value="Half-Orc">{T.raceHalfOrc}</option>
</select>
</div>
<!-- Role -->
<div>
<label for="npc-role" class={labelClasses}>
{T.aiFormRole}
</label>
<select id="npc-role" name="role" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Warrior">{T.roleWarrior}</option>
<option value="Mage">{T.roleMage}</option>
<option value="Rogue">{T.roleRogue}</option>
<option value="Cleric">{T.roleCleric}</option>
<option value="Ranger">{T.roleRanger}</option>
<option value="Paladin">{T.rolePaladin}</option>
<option value="Bard">{T.roleBard}</option>
<option value="Barbarian">{T.roleBarbarian}</option>
<option value="Druid">{T.roleDruid}</option>
<option value="Warlock">{T.roleWarlock}</option>
</select>
</div>
<!-- Level -->
<div>
<label for="npc-level" class={labelClasses}>
{T.aiFormLevel}
</label>
<input
id="npc-level"
name="level"
type="number"
min="1"
max="20"
value="1"
class="w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)]"
required
disabled={disabled}
/>
</div>
<!-- Tone -->
<div>
<label for="npc-tone" class={labelClasses}>
{T.aiFormTone}
</label>
<select id="npc-tone" name="tone" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Serious">{T.toneSerious}</option>
<option value="Humorous">{T.toneHumorous}</option>
<option value="Dark">{T.toneDark}</option>
<option value="Heroic">{T.toneHeroic}</option>
<option value="Mysterious">{T.toneMysterious}</option>
</select>
</div>
<!-- Open5e Reference Toggle -->
<div class="flex items-center gap-3">
<input
id="npc-use-reference"
name="useOpen5eReference"
type="checkbox"
class="w-5 h-5 rounded border-[var(--border-color)] bg-[var(--bg-input)] text-[var(--accent)] accent-[var(--accent)] focus:ring-[var(--gold)] focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--bg-primary)] cursor-pointer"
disabled={disabled}
/>
<label for="npc-use-reference" class="text-sm text-[var(--text-secondary)] cursor-pointer">
{T.aiFormUseReference}
</label>
</div>
<!-- Submit -->
<div class="pt-2">
<DmButton
id="ai-npc-submit"
type="submit"
variant="primary"
size="md"
class="w-full"
disabled={disabled}
dataTestid="ai-npc-submit"
>
{T.aiFormSubmit}
</DmButton>
</div>
<!-- Status -->
<div
id="ai-npc-status"
class="text-sm text-center min-h-[1.5rem]"
aria-live="polite"
data-testid="ai-npc-status"
></div>
</form>
</DmCard>
<script is:inline define:vars={{ generatingLabel: T.npcGenerating, submitLabel: T.npcGenerate, rateLimitTemplate: T.npcErrorRateLimit, genericError: T.npcErrorGeneric }}>
(function () {
const form = document.getElementById("ai-npc-form") as HTMLFormElement | null;
const submitBtn = document.getElementById("ai-npc-submit") as HTMLButtonElement | null;
const statusEl = document.getElementById("ai-npc-status");
if (!form || !submitBtn || !statusEl) return;
let isSubmitting = false;
function setStatus(message: string, type: "error" | "info" = "info") {
statusEl.textContent = message;
statusEl.className =
"text-sm text-center min-h-[1.5rem] " +
(type === "error" ? "text-[var(--danger)]" : "text-[var(--text-secondary)]");
}
function setLoading(loading: boolean) {
isSubmitting = loading;
submitBtn.disabled = loading;
submitBtn.textContent = loading ? generatingLabel : submitLabel;
submitBtn.setAttribute("aria-busy", loading ? "true" : "false");
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (isSubmitting) return;
const formData = new FormData(form);
const body = {
race: String(formData.get("race")),
role: String(formData.get("role")),
level: parseInt(String(formData.get("level")), 10),
tone: String(formData.get("tone")),
useOpen5eReference: formData.get("useOpen5eReference") === "on",
};
setLoading(true);
setStatus("");
try {
const response = await fetch("/api/dm/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const minutes = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 60) : 60;
setStatus(rateLimitTemplate.replace("{minutes}", String(minutes)), "error");
setLoading(false);
return;
}
if (!response.ok) {
setStatus(genericError, "error");
setLoading(false);
return;
}
const result = await response.json();
if (!result.npc) {
setStatus(genericError, "error");
setLoading(false);
return;
}
setStatus("");
window.dispatchEvent(
new CustomEvent("npc-generated", {
detail: result.npc,
bubbles: true,
})
);
} catch {
setStatus(genericError, "error");
} finally {
setLoading(false);
}
});
})();
</script>
+172
View File
@@ -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;
---
<DmCard padding="lg" dataTestid="ai-npc-result">
<div class="flex items-start justify-between gap-4 mb-5">
<div class="min-w-0">
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">
{npc.name}
</h3>
<p class="text-sm text-[var(--text-secondary)] mt-1">
{npc.race} · {npc.role} · {T.npcLevel} {npc.level} · {T.npcCr} {npc.cr}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<DmButton
variant="secondary"
size="sm"
id="npc-copy-btn"
dataTestid="npc-copy-btn"
>
{T.npcCopyJson}
</DmButton>
{
onRegenerate && (
<DmButton
variant="primary"
size="sm"
id="npc-regen-btn"
dataTestid="npc-regen-btn"
>
{T.npcRegenerate}
</DmButton>
)
}
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-hp"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcHp}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.hp}</div>
</div>
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-ac"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcAc}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.ac}</div>
</div>
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-speed"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcSpeed}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.speed}</div>
</div>
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-cr"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcCr}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.cr}</div>
</div>
</div>
<div class="space-y-3 mb-5">
<div data-testid="npc-appearance">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcAppearance}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.appearance}
</p>
</div>
<div data-testid="npc-trait">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcTrait}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.trait}
</p>
</div>
<div data-testid="npc-motivation">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcMotivation}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.motivation}
</p>
</div>
<div data-testid="npc-secret">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcSecret}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.secret}
</p>
</div>
</div>
<div
class="border-l-4 border-[var(--accent)] pl-4 py-2 italic text-[var(--text-cream)] bg-[var(--bg-secondary)]/50 rounded-r-lg mb-5"
data-testid="npc-history"
>
<p class="text-sm leading-relaxed">{npc.history}</p>
</div>
<div class="flex flex-wrap gap-2">
<span
class="px-3 py-1 text-xs font-medium rounded-full bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20"
>
{npc.race}
</span>
<span
class="px-3 py-1 text-xs font-medium rounded-full bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20"
>
{npc.role}
</span>
</div>
</DmCard>
<script is:inline define:vars={{ npcJson: JSON.stringify(npc), copiedLabel: T.npcCopied, hasRegenerate: !!onRegenerate }}>
(function () {
const copyBtn = document.getElementById("npc-copy-btn");
const regenBtn = document.getElementById("npc-regen-btn");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(npcJson);
const originalText = copyBtn.textContent || "";
copyBtn.textContent = copiedLabel;
setTimeout(() => {
copyBtn.textContent = originalText;
}, 1500);
} catch {
/* ignore clipboard errors */
}
});
}
if (regenBtn && hasRegenerate) {
regenBtn.addEventListener("click", () => {
regenBtn.dispatchEvent(
new CustomEvent("npc-regenerate", { bubbles: true })
);
});
}
})();
</script>
+335
View File
@@ -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;
---
<div
class="generation-history"
data-tier={tier}
data-user-id={userId}
data-testid="generation-history"
>
<DmCard padding="md">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{T.historyTitle}
</h3>
<span
id="history-count"
class="text-xs text-[var(--text-muted)]"
data-testid="history-count"
>
0
</span>
</div>
<div id="history-loading" class="hidden py-4 text-center text-[var(--text-muted)] text-sm">
{T.loading}
</div>
<div
id="history-empty-pro"
class="hidden flex flex-col items-center justify-center py-6 text-[var(--text-muted)]"
data-testid="history-empty-pro"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-2 opacity-40"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span class="text-sm">{T.historyEmptyPro}</span>
</div>
<div
id="history-empty-free"
class="hidden flex flex-col items-center justify-center py-6 px-4 rounded-lg border-2 border-dashed border-[var(--border-color)] text-center"
data-testid="history-empty-free"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-2 text-[var(--accent)] opacity-60"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p class="text-sm text-[var(--text-secondary)] mb-2">{T.historyEmptyFree}</p>
<a
href="/dm/upgrade"
class="inline-flex items-center justify-center rounded-lg px-4 py-1.5 text-sm font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors"
>
{T.historyUpgradeCta}
</a>
</div>
<ul
id="history-list"
class="space-y-1 max-h-60 overflow-y-auto hidden"
role="list"
aria-label={T.historyTitle}
data-testid="history-list"
>
</ul>
</DmCard>
</div>
<script>
import type { NPCResult } from '@/lib/ai/types';
interface HistoryItem {
id: number;
name: string;
race: string | null;
role: string | null;
level: number | null;
content: Record<string, unknown>;
createdAt: string;
}
const FREE_STORAGE_KEY = 'dm-ai-history-free';
const MAX_HISTORY = 20;
function loadFreeHistory(): HistoryItem[] {
try {
const raw = sessionStorage.getItem(FREE_STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as HistoryItem[];
} catch {
return [];
}
}
function saveFreeHistory(items: HistoryItem[]) {
try {
sessionStorage.setItem(FREE_STORAGE_KEY, JSON.stringify(items.slice(0, MAX_HISTORY)));
} catch {
// sessionStorage may be unavailable
}
}
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
}
function renderHistory(container: HTMLElement, items: HistoryItem[], activeId: number | null) {
const list = container.querySelector<HTMLUListElement>('#history-list');
const emptyPro = container.querySelector<HTMLDivElement>('#history-empty-pro');
const emptyFree = container.querySelector<HTMLDivElement>('#history-empty-free');
const countEl = container.querySelector<HTMLSpanElement>('#history-count');
if (!list || !emptyPro || !emptyFree || !countEl) return;
countEl.textContent = String(items.length);
if (items.length === 0) {
list.classList.add('hidden');
list.innerHTML = '';
const tier = container.dataset.tier;
if (tier === 'pro') {
emptyPro.classList.remove('hidden');
emptyFree.classList.add('hidden');
} else {
emptyPro.classList.add('hidden');
emptyFree.classList.remove('hidden');
}
return;
}
emptyPro.classList.add('hidden');
emptyFree.classList.add('hidden');
list.classList.remove('hidden');
list.innerHTML = items
.map((item) => {
const isActive = item.id === activeId;
const raceRole = [item.race, item.role].filter(Boolean).join(' · ');
const levelText = item.level ? `Ур. ${item.level}` : '';
const meta = [raceRole, levelText].filter(Boolean).join(' · ');
const dateText = formatDate(item.createdAt);
const timeText = formatTime(item.createdAt);
return `
<li
data-history-id="${item.id}"
class="group cursor-pointer rounded-lg px-3 py-2.5 text-sm transition-all duration-[var(--transition-base)] border ${isActive
? 'bg-[var(--accent)]/10 border-[var(--accent)]/40'
: 'bg-[var(--bg-secondary)] border-transparent hover:bg-[var(--bg-secondary)] hover:border-[var(--border-color)]'
}"
role="button"
tabindex="0"
aria-pressed="${isActive}"
>
<div class="flex items-center justify-between">
<span class="font-medium ${isActive ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'} truncate pr-2">
${escapeHtml(item.name)}
</span>
<span class="text-xs text-[var(--text-muted)] shrink-0">
${escapeHtml(dateText)} ${escapeHtml(timeText)}
</span>
</div>
${meta ? `<div class="text-xs text-[var(--text-secondary)] mt-0.5 truncate">${escapeHtml(meta)}</div>` : ''}
</li>
`;
})
.join('');
list.querySelectorAll<HTMLLIElement>('li[data-history-id]').forEach((li) => {
li.addEventListener('click', () => {
const id = Number(li.dataset.historyId);
selectItem(container, items, id);
});
li.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const id = Number(li.dataset.historyId);
selectItem(container, items, id);
}
});
});
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function selectItem(container: HTMLElement, items: HistoryItem[], id: number) {
const item = items.find((i) => i.id === id);
if (!item) return;
renderHistory(container, items, id);
const npcData: NPCResult & { id: number } = {
...(item.content as unknown as NPCResult),
id: item.id,
};
container.dispatchEvent(
new CustomEvent('npc-selected', {
detail: npcData,
bubbles: true,
})
);
}
async function fetchProHistory(userId: number): Promise<HistoryItem[]> {
const response = await fetch(`/api/dm/ai/history?limit=${MAX_HISTORY}`);
if (!response.ok) {
if (response.status === 401) {
return [];
}
throw new Error(`Failed to fetch history: ${response.status}`);
}
const data = (await response.json()) as { items: HistoryItem[]; total: number };
return data.items;
}
function initGenerationHistory(container: HTMLElement) {
const tier = container.dataset.tier as 'free' | 'pro' | undefined;
const userId = container.dataset.userId ? Number(container.dataset.userId) : undefined;
const loadingEl = container.querySelector<HTMLDivElement>('#history-loading');
let items: HistoryItem[] = [];
let activeId: number | null = null;
function showLoading() {
loadingEl?.classList.remove('hidden');
}
function hideLoading() {
loadingEl?.classList.add('hidden');
}
async function loadHistory() {
if (tier === 'pro' && userId) {
showLoading();
try {
items = await fetchProHistory(userId);
} catch {
items = [];
} finally {
hideLoading();
}
} else {
items = loadFreeHistory();
}
renderHistory(container, items, activeId);
}
function handleNpcGenerated(e: Event) {
const event = e as CustomEvent<NPCResult & { id: number }>;
const npc = event.detail;
if (!npc || !npc.id) return;
const newItem: HistoryItem = {
id: npc.id,
name: npc.name,
race: npc.race ?? null,
role: npc.role ?? null,
level: npc.level ?? null,
content: npc as unknown as Record<string, unknown>,
createdAt: new Date().toISOString(),
};
items = items.filter((i) => i.id !== newItem.id);
items.unshift(newItem);
if (items.length > MAX_HISTORY) {
items.length = MAX_HISTORY;
}
if (tier !== 'pro') {
saveFreeHistory(items);
}
renderHistory(container, items, activeId);
}
container.addEventListener('npc-generated', handleNpcGenerated);
window.addEventListener('npc-generated', handleNpcGenerated);
loadHistory();
}
document.querySelectorAll<HTMLElement>('[data-testid="generation-history"]').forEach((el) => {
initGenerationHistory(el);
});
</script>
+224 -1
View File
@@ -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;
---
<div id="initiative-tracker" class="w-full max-w-xl mx-auto" data-testid="init-section">
<div
id="initiative-tracker"
class="w-full max-w-xl mx-auto"
data-testid="init-section"
data-tier={tier}
data-user-id={userId}
>
<!-- PRO session management -->
{tier === 'pro' && (
<div class="mb-6">
<DmCard>
<div class="flex flex-col sm:flex-row items-end gap-2 mb-2">
<div class="flex-1 w-full">
<DmInput
id="it-session-name"
type="text"
placeholder={T.sessionNamePlaceholder}
label=""
dataTestid="it-session-name"
/>
</div>
<DmButton id="it-save-session-btn" variant="primary" size="sm" dataTestid="it-save-session-btn">
{T.saveSession}
</DmButton>
<DmButton id="it-new-session-btn" variant="secondary" size="sm" dataTestid="it-new-session-btn">
{T.newSession}
</DmButton>
</div>
<div
id="it-session-status"
class="text-sm min-h-[1.25rem] text-[var(--text-secondary)]"
data-testid="it-session-status"
></div>
<div
id="it-session-list"
class="mt-2 max-h-40 overflow-y-auto space-y-1"
data-testid="it-session-list"
></div>
</DmCard>
</div>
)}
<!-- Add combatant form -->
<div class="mb-6">
<DmCard>
@@ -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<DbSession[]> {
const res = await fetch('/api/dm/initiative');
if (!res.ok) return [];
return res.json() as Promise<DbSession[]>;
}
async function apiSaveSession(name: string, participants: CombatantData[], id?: number): Promise<DbSession | null> {
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<DbSession>;
} 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<DbSession>;
}
}
async function apiDeleteSession(id: number): Promise<boolean> {
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 = `<p class="text-xs text-[var(--text-muted)] py-2">Нет сохранённых сессий</p>`;
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 `
<div class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-sm ${isActive ? 'bg-[var(--accent)]/10 border border-[var(--accent)]/30' : 'bg-[var(--bg-secondary)] border border-transparent hover:border-[var(--border-color)]'} transition-colors">
<button type="button" class="it-load-session flex-1 text-left truncate ${isActive ? 'text-[var(--accent)] font-medium' : 'text-[var(--text-primary)]'}" data-id="${s.id}">
${escapeHtml(s.name)} <span class="text-[var(--text-muted)] text-xs">${date}</span>
</button>
<button type="button" class="it-delete-session w-6 h-6 inline-flex items-center justify-center rounded text-[var(--text-secondary)] hover:text-red-400 transition-colors" data-id="${s.id}" aria-label="Удалить ${escapeHtml(s.name)}">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
</div>
`;
}).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();
</script>
+129 -14
View File
@@ -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;
---
<DmCard padding="md" dataTestid="notes-section">
<DmCard padding="md" dataTestid="notes-section" data-tier={tier} data-user-id={userId}>
<textarea
id="notes-textarea"
class="w-full h-64 xl:h-80 2xl:h-96 bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg p-4 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] transition-all resize-none"
@@ -35,15 +42,21 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
const clearBtn = container.querySelector('[data-testid="notes-clear-btn"]') as HTMLButtonElement;
const copyBtn = container.querySelector('#notes-copy-btn') as HTMLButtonElement;
const tier = (container.dataset.tier as 'free' | 'pro') || 'free';
const userId = container.dataset.userId ? parseInt(container.dataset.userId, 10) : undefined;
const isPro = tier === 'pro' && !!userId;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let hideIndicatorTimer: ReturnType<typeof setTimeout> | null = null;
let dbNoteId: number | null = null;
let isLoading = false;
function formatTime(date: Date): string {
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
function showSavedIndicator(message?: string) {
const text = message || `Сохранено · ${formatTime(new Date())}`;
const text = message || `Сохранено \u00b7 ${formatTime(new Date())}`;
savedIndicator.textContent = text;
savedIndicator.classList.remove('opacity-0');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
@@ -52,29 +65,128 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
}, 1500);
}
function showErrorIndicator(message: string) {
savedIndicator.textContent = message;
savedIndicator.classList.remove('opacity-0');
savedIndicator.classList.add('text-[var(--danger)]');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
hideIndicatorTimer = setTimeout(() => {
savedIndicator.classList.add('opacity-0');
savedIndicator.classList.remove('text-[var(--danger)]');
}, 3000);
}
function updateCharCount() {
const count = textarea.value.length;
charCount.textContent = String(count);
}
function loadSavedNotes() {
const saved = loadNotes();
textarea.value = saved;
async function loadDbNotes(): Promise<{ id: number; content: string | null } | null> {
try {
const res = await fetch('/api/dm/notes');
if (!res.ok) return null;
const notes = await res.json() as Array<{ id: number; title: string; content: string | null }>;
const note = notes.find(n => n.title === 'DM Notes') || notes[0];
return note ?? null;
} catch {
return null;
}
}
async function createDbNote(content: string): Promise<number | null> {
try {
const res = await fetch('/api/dm/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'DM Notes', content }),
});
if (!res.ok) return null;
const data = await res.json() as { id: number };
return data.id;
} catch {
return null;
}
}
async function updateDbNote(id: number, content: string): Promise<boolean> {
try {
const res = await fetch(`/api/dm/notes?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
return res.ok;
} catch {
return false;
}
}
async function loadSavedNotes() {
if (isPro) {
isLoading = true;
const note = await loadDbNotes();
if (note) {
dbNoteId = note.id;
textarea.value = note.content ?? '';
} else {
// Try to create a new note if none exists
const newId = await createDbNote('');
if (newId) {
dbNoteId = newId;
textarea.value = '';
} else {
// Fallback to localStorage if DB is unavailable
textarea.value = loadNotes();
}
}
isLoading = false;
} else {
const saved = loadNotes();
textarea.value = saved;
}
updateCharCount();
}
function handleInput() {
async function handleInput() {
updateCharCount();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
saveNotes(textarea.value);
showSavedIndicator();
debounceTimer = setTimeout(async () => {
if (isPro) {
if (dbNoteId) {
const ok = await updateDbNote(dbNoteId, textarea.value);
if (ok) {
showSavedIndicator();
} else {
saveNotes(textarea.value);
showErrorIndicator('Ошибка сохранения');
}
} else {
const newId = await createDbNote(textarea.value);
if (newId) {
dbNoteId = newId;
showSavedIndicator();
} else {
saveNotes(textarea.value);
showErrorIndicator('Ошибка сохранения');
}
}
} else {
saveNotes(textarea.value);
showSavedIndicator();
}
}, 500);
}
function handleClear() {
async function handleClear() {
textarea.value = '';
clearNotes();
if (isPro && dbNoteId) {
const ok = await updateDbNote(dbNoteId, '');
if (!ok) {
clearNotes();
}
} else {
clearNotes();
}
updateCharCount();
savedIndicator.classList.add('opacity-0');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
@@ -99,9 +211,12 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
function handleStorageChange(e: StorageEvent) {
if (e.key === 'dm-notes' && e.newValue !== null) {
textarea.value = e.newValue;
updateCharCount();
showSavedIndicator('Обновлено');
// Only apply localStorage changes if not PRO (or if PRO but DB failed)
if (!isPro) {
textarea.value = e.newValue;
updateCharCount();
showSavedIndicator('Обновлено');
}
}
}
+8 -41
View File
@@ -171,6 +171,11 @@ import DmButton from "./DmButton.astro";
<script>
import { Open5eUIManager } from "@/lib/client/open5e-ui";
import {
getCachedTranslation,
setCachedTranslation,
fetchTranslation,
} from "@/lib/client/translation";
const CR_OPTIONS = [
{ value: "", label: "Все ОП" },
@@ -230,39 +235,9 @@ import DmButton from "./DmButton.astro";
return div.innerHTML;
}
const TRANSLATION_CACHE_KEY = "o5-translations";
function loadTranslationCache(): Record<string, Monster | Spell> {
try {
const raw = localStorage.getItem(TRANSLATION_CACHE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function saveTranslationCache(cache: Record<string, Monster | Spell>) {
try {
localStorage.setItem(TRANSLATION_CACHE_KEY, JSON.stringify(cache));
} catch {
/* ignore quota errors */
}
}
function getCachedTranslation(key: string, type: "creature" | "spell"): Monster | Spell | null {
const cache = loadTranslationCache();
return cache[`${type}:${key}`] ?? null;
}
function setCachedTranslation(key: string, type: "creature" | "spell", data: Monster | Spell) {
const cache = loadTranslationCache();
cache[`${type}:${key}`] = data;
saveTranslationCache(cache);
}
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: T | null): T {
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: Record<string, unknown> | null): T {
if (!translated) return item;
return { ...item, ...translated };
return { ...item, ...translated } as T;
}
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
@@ -564,15 +539,7 @@ import DmButton from "./DmButton.astro";
btn.disabled = true;
try {
const res = await fetch(`/api/dm/translate?slug=${encodeURIComponent(slug)}&type=${type}`);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) {
throw new Error(data.error || "Ошибка перевода");
}
const translated = data.translated as Monster | Spell;
setCachedTranslation(slug, type, translated);
const translated = await fetchTranslation(slug, type);
btn.textContent = "Переведено";
btn.disabled = true;
+84
View File
@@ -89,6 +89,90 @@ export const dmTranslations = {
aiSignInCta: "Войдите, чтобы использовать ИИ",
aiPlaceholder: "Генератор ИИ появится здесь",
// AI Form
aiFormRace: "Раса",
aiFormRole: "Класс / Роль",
aiFormLevel: "Уровень",
aiFormTone: "Тон",
aiFormUseReference: "Использовать справочник Open5e",
aiFormSubmit: "Сгенерировать NPC",
aiFormSelectOption: "Выберите...",
// Races
raceHuman: "Человек",
raceElf: "Эльф",
raceDwarf: "Дварф",
raceHalfling: "Полурослик",
raceOrc: "Орк",
raceTiefling: "Тифлинг",
raceDragonborn: "Драконорождённый",
raceGnome: "Гном",
raceHalfElf: "Полуэльф",
raceHalfOrc: "Полуорк",
// Roles
roleWarrior: "Воин",
roleMage: "Маг",
roleRogue: "Плут",
roleCleric: "Жрец",
roleRanger: "Следопыт",
rolePaladin: "Паладин",
roleBard: "Бард",
roleBarbarian: "Варвар",
roleDruid: "Друид",
roleWarlock: "Колдун",
// Tones
toneSerious: "Серьёзный",
toneHumorous: "Юмористический",
toneDark: "Тёмный",
toneHeroic: "Героический",
toneMysterious: "Загадочный",
// AI NPC Form
npcRace: "Раса",
npcRole: "Класс / Роль",
npcLevel: "Уровень",
npcTone: "Тон",
npcUseOpen5e: "Использовать справочник Open5e",
npcGenerate: "Сгенерировать NPC",
npcGenerating: "Генерация...",
npcErrorGeneric: "Ошибка генерации. Попробуйте снова.",
npcErrorRateLimit: "Превышен лимит генераций. Попробуйте через {minutes} мин.",
npcErrorValidation: "Проверьте введённые данные.",
// AI History
historyTitle: "История генераций",
historyEmptyPro: "Нет сохранённых NPC",
historyEmptyFree: "История сохраняется только для PRO",
historyUpgradeCta: "Перейти на PRO",
// NPC result card
npcName: "Имя",
npcHp: "ХП",
npcAc: "КЗ",
npcCr: "ОП",
npcSpeed: "Скорость",
npcAppearance: "Внешность",
npcTrait: "Черта",
npcMotivation: "Мотивация",
npcSecret: "Тайна",
npcHistory: "История",
npcCopyJson: "Копировать JSON",
npcCopied: "Скопировано",
npcRegenerate: "Перегенерировать",
saveSession: "Сохранить сессию",
newSession: "Новая сессия",
sessionNamePlaceholder: "Название сессии",
noSavedSessions: "Нет сохранённых сессий",
sessionSaved: "Сессия сохранена",
saveError: "Ошибка сохранения",
loadSessionError: "Ошибка загрузки сессии",
deleteSessionConfirm: "Удалить сессию?",
noteSaveError: "Ошибка сохранения заметок",
noteLoadError: "Ошибка загрузки заметок",
// Auth
loginVk: "Войти через VK",
loginYandex: "Войти через Яндекс",
+76
View File
@@ -0,0 +1,76 @@
export interface TranslationEntry {
slug: string;
type: "creature" | "spell";
data: Record<string, unknown>;
timestamp: number;
}
const CACHE = new Map<string, TranslationEntry>();
const CHANNEL =
typeof BroadcastChannel !== "undefined"
? new BroadcastChannel("dm-translations")
: null;
if (CHANNEL) {
CHANNEL.addEventListener("message", (e) => {
const entry = e.data as TranslationEntry;
CACHE.set(`${entry.type}:${entry.slug}`, entry);
});
}
export function getCachedTranslation(
slug: string,
type: "creature" | "spell",
): Record<string, unknown> | null {
return CACHE.get(`${type}:${slug}`)?.data ?? null;
}
export function setCachedTranslation(
slug: string,
type: "creature" | "spell",
data: Record<string, unknown>,
): void {
const entry: TranslationEntry = {
slug,
type,
data,
timestamp: Date.now(),
};
CACHE.set(`${type}:${slug}`, entry);
CHANNEL?.postMessage(entry);
}
export async function fetchTranslation(
slug: string,
type: "creature" | "spell",
): Promise<Record<string, unknown> | null> {
const cached = getCachedTranslation(slug, type);
if (cached) return cached;
const res = await fetch(
`/api/dm/translate?slug=${encodeURIComponent(slug)}&type=${type}`,
);
const data = (await res.json().catch(() => ({}))) as {
error?: string;
translated?: Record<string, unknown>;
};
if (!res.ok || data.error) {
throw new Error(data.error || "Translation failed");
}
const translated = data.translated ?? null;
if (translated) {
setCachedTranslation(slug, type, translated);
}
return translated;
}
export function clearTranslationCache(): void {
CACHE.clear();
}
export function getTranslationCacheSize(): number {
return CACHE.size;
}
+4 -28
View File
@@ -8,6 +8,7 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
@@ -45,7 +46,7 @@ if (user) {
{T.initiative}
</h2>
</div>
<InitiativeTracker />
<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />
</section>
<!-- AI Generator -->
@@ -57,32 +58,7 @@ if (user) {
</h2>
</div>
{Astro.locals.user ? (
<DmCard>
<div class="p-6 text-center text-[var(--text-secondary)]">
<p>{T.aiPlaceholder}</p>
</div>
</DmCard>
) : (
<DmCard>
<div class="p-6 text-center">
<p class="text-[var(--text-secondary)] mb-4">{T.aiSignInCta}</p>
<a
href="/api/auth/login/vk"
class="inline-flex items-center justify-center rounded-lg px-5 py-2.5 text-sm font-medium bg-[#0077FF] text-white hover:bg-[#0066CC] transition-colors"
>
{T.loginVk}
</a>
</div>
</DmCard>
)}
</section>
</div>
{Astro.locals.user ? (
<DmCard>
<div class="p-6 text-center text-[var(--text-secondary)]">
<p>{T.aiPlaceholder}</p>
</div>
</DmCard>
<AiNpcForm />
) : (
<DmCard>
<div class="p-6 text-center">
@@ -123,7 +99,7 @@ if (user) {
</h2>
</div>
<DmCard>
<NotesPanel />
<NotesPanel tier={user?.tier ?? 'free'} userId={user?.id} />
</DmCard>
</section>
</div>
+9 -10
View File
@@ -8,6 +8,7 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
@@ -45,7 +46,7 @@ if (user) {
{T.initiative}
</h2>
</div>
<InitiativeTracker />
<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />
</section>
<!-- AI Generator -->
@@ -56,17 +57,15 @@ if (user) {
{T.ai}
</h2>
</div>
<DmCard>
{Astro.locals.user ? (
<div class="p-6 text-center text-[var(--text-secondary)]">
<p>{T.aiPlaceholder}</p>
</div>
) : (
{Astro.locals.user ? (
<AiNpcForm />
) : (
<DmCard>
<div class="p-6 text-center">
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
</div>
)}
</DmCard>
</DmCard>
)}
</section>
</div>
@@ -94,7 +93,7 @@ if (user) {
</h2>
</div>
<DmCard>
<NotesPanel />
<NotesPanel tier={user?.tier ?? 'free'} userId={user?.id} />
</DmCard>
</section>
</div>
+6 -6
View File
@@ -1,9 +1,9 @@
.dm-theme {
/* Primary Accents */
--accent: #534AB7;
--accent-light: #6b5fc7;
--accent-dark: #3f3791;
--accent-hover: #6b5fc7;
/* Primary Accents — Orange for DM Dashboard */
--accent: #E87722;
--accent-light: #f08a3e;
--accent-dark: #c45f18;
--accent-hover: #f08a3e;
/* Secondary (Gold) */
--gold: #c8a84b;
@@ -42,7 +42,7 @@
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-glow: 0 0 20px rgba(83, 74, 183, 0.3);
--shadow-glow: 0 0 20px rgba(232, 119, 34, 0.3);
/* Focus Ring */
--focus-ring: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
+146
View File
@@ -0,0 +1,146 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockPostMessage = vi.fn();
const mockAddEventListener = vi.fn();
function MockBroadcastChannel(this: unknown, _name: string) {
return {
postMessage: mockPostMessage,
addEventListener: mockAddEventListener,
close: vi.fn(),
};
}
vi.stubGlobal("BroadcastChannel", MockBroadcastChannel as unknown as typeof BroadcastChannel);
describe("translation service", () => {
beforeEach(() => {
vi.resetModules();
mockPostMessage.mockClear();
mockAddEventListener.mockClear();
});
it("stores and retrieves cached translations", async () => {
const { getCachedTranslation, setCachedTranslation, clearTranslationCache } =
await import("../src/lib/client/translation");
clearTranslationCache();
expect(getCachedTranslation("goblin", "creature")).toBeNull();
setCachedTranslation("goblin", "creature", { name: "Гоблин" });
expect(getCachedTranslation("goblin", "creature")).toEqual({ name: "Гоблин" });
expect(getCachedTranslation("dragon", "creature")).toBeNull();
});
it("broadcasts cache updates via BroadcastChannel", async () => {
const { setCachedTranslation, clearTranslationCache } = await import(
"../src/lib/client/translation"
);
clearTranslationCache();
setCachedTranslation("goblin", "creature", { name: "Гоблин" });
expect(mockPostMessage).toHaveBeenCalledWith(
expect.objectContaining({
slug: "goblin",
type: "creature",
data: { name: "Гоблин" },
}),
);
});
it("fetches translation from API when not cached", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ translated: { name: "Гоблин" } }),
});
global.fetch = mockFetch as unknown as typeof fetch;
const { fetchTranslation, clearTranslationCache } = await import(
"../src/lib/client/translation"
);
clearTranslationCache();
const result = await fetchTranslation("goblin", "creature");
expect(mockFetch).toHaveBeenCalledWith(
"/api/dm/translate?slug=goblin&type=creature",
);
expect(result).toEqual({ name: "Гоблин" });
});
it("returns cached translation without fetching", async () => {
const mockFetch = vi.fn();
global.fetch = mockFetch as unknown as typeof fetch;
const {
getCachedTranslation,
setCachedTranslation,
fetchTranslation,
clearTranslationCache,
} = await import("../src/lib/client/translation");
clearTranslationCache();
setCachedTranslation("goblin", "creature", { name: "Гоблин" });
const result = await fetchTranslation("goblin", "creature");
expect(mockFetch).not.toHaveBeenCalled();
expect(result).toEqual({ name: "Гоблин" });
});
it("handles fetch errors", async () => {
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
json: async () => ({ error: "Not found" }),
});
global.fetch = mockFetch as unknown as typeof fetch;
const { fetchTranslation, clearTranslationCache } = await import(
"../src/lib/client/translation"
);
clearTranslationCache();
await expect(fetchTranslation("unknown", "creature")).rejects.toThrow(
"Not found",
);
});
it("does not use localStorage", async () => {
const {
getCachedTranslation,
setCachedTranslation,
clearTranslationCache,
} = await import("../src/lib/client/translation");
clearTranslationCache();
const setItemSpy = vi.spyOn(Storage.prototype, "setItem");
const getItemSpy = vi.spyOn(Storage.prototype, "getItem");
setCachedTranslation("goblin", "creature", { name: "Гоблин" });
getCachedTranslation("goblin", "creature");
expect(setItemSpy).not.toHaveBeenCalled();
expect(getItemSpy).not.toHaveBeenCalled();
setItemSpy.mockRestore();
getItemSpy.mockRestore();
});
it("tracks cache size", async () => {
const {
setCachedTranslation,
getTranslationCacheSize,
clearTranslationCache,
} = await import("../src/lib/client/translation");
clearTranslationCache();
expect(getTranslationCacheSize()).toBe(0);
setCachedTranslation("a", "creature", { name: "A" });
setCachedTranslation("b", "spell", { name: "B" });
expect(getTranslationCacheSize()).toBe(2);
});
});