feat(dm-dashboard): Wave 6 — NPC form, result card, history panel, notes/initiative DB migration, translation service
This commit is contained in:
@@ -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).
|
- 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.
|
- This allows the mock to correctly filter by `userId` and `id`, making cross-user access tests reliable.
|
||||||
|
|
||||||
6. **Test coverage**
|
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.
|
- `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`)
|
- `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
|
- `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.
|
||||||
|
|
||||||
|
|||||||
@@ -1198,7 +1198,7 @@ Max Concurrent: 6 (Wave 6)
|
|||||||
- Message: `feat(ui): add AI tab to DM navigation`
|
- 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`
|
- 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**:
|
**What to do**:
|
||||||
- Create `src/components/dm/AiNpcForm.astro`: Form for NPC generation parameters.
|
- 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`
|
- Message: `feat(ui): add NPC generator form component`
|
||||||
- Files: `src/components/dm/AiNpcForm.astro`
|
- Files: `src/components/dm/AiNpcForm.astro`
|
||||||
|
|
||||||
- [ ] **17. Build NPC Result Card Component**
|
- [x] **17. Build NPC Result Card Component**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/components/dm/AiNpcResult.astro`: Rich card displaying generated NPC.
|
- 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`
|
- Message: `feat(ui): add NPC result card component`
|
||||||
- Files: `src/components/dm/AiNpcResult.astro`
|
- Files: `src/components/dm/AiNpcResult.astro`
|
||||||
|
|
||||||
- [ ] **18. Build Generation History Panel Component**
|
- [x] **18. Build Generation History Panel Component**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/components/dm/GenerationHistory.astro`: Right-panel list of generated NPCs.
|
- 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`
|
- 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`
|
- 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**:
|
**What to do**:
|
||||||
- Update `src/components/dm/NotesPanel.astro`: Add tier-aware persistence.
|
- 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`
|
- Message: `feat(pro): add notes DB persistence for PRO users`
|
||||||
- Files: `src/components/dm/NotesPanel.astro`, `src/lib/client/notes.ts`
|
- 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**:
|
**What to do**:
|
||||||
- Update `src/components/dm/InitiativeTracker.astro`: Add tier-aware persistence.
|
- 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`
|
- Message: `feat(ui): add translate button to Open5e reference cards`
|
||||||
- Files: `src/components/dm/Open5eReference.astro`
|
- Files: `src/components/dm/Open5eReference.astro`
|
||||||
|
|
||||||
- [ ] **24. Wire Translation Service to UI**
|
- [x] **24. Wire Translation Service to UI**
|
||||||
|
|
||||||
**What to do**:
|
**What to do**:
|
||||||
- Create `src/lib/client/translation.ts`: Client-side translation cache and API caller.
|
- Create `src/lib/client/translation.ts`: Client-side translation cache and API caller.
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -1,11 +1,59 @@
|
|||||||
---
|
---
|
||||||
|
export interface Props {
|
||||||
|
tier?: 'free' | 'pro';
|
||||||
|
userId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
import DmButton from "./DmButton.astro";
|
import DmButton from "./DmButton.astro";
|
||||||
import DmInput from "./DmInput.astro";
|
import DmInput from "./DmInput.astro";
|
||||||
import DmCard from "./DmCard.astro";
|
import DmCard from "./DmCard.astro";
|
||||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
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 -->
|
<!-- Add combatant form -->
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<DmCard>
|
<DmCard>
|
||||||
@@ -106,6 +154,7 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
|
|||||||
const STORAGE_KEY = "dm-initiative";
|
const STORAGE_KEY = "dm-initiative";
|
||||||
|
|
||||||
// DOM refs
|
// DOM refs
|
||||||
|
const trackerEl = document.getElementById("initiative-tracker");
|
||||||
const nameInput = document.getElementById("it-name") as HTMLInputElement;
|
const nameInput = document.getElementById("it-name") as HTMLInputElement;
|
||||||
const modifierInput = document.getElementById(
|
const modifierInput = document.getElementById(
|
||||||
"it-modifier",
|
"it-modifier",
|
||||||
@@ -122,7 +171,20 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
|
|||||||
const emptyState = document.getElementById("it-empty");
|
const emptyState = document.getElementById("it-empty");
|
||||||
const announcer = document.getElementById("it-announcer");
|
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 activeIndex = 0;
|
||||||
|
let currentSessionId: number | null = null;
|
||||||
|
let currentSessionName = '';
|
||||||
|
|
||||||
interface CombatantData {
|
interface CombatantData {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -392,6 +454,163 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
|
|||||||
initiativeInput.value = String(result);
|
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
|
// Event listeners
|
||||||
rollBtn?.addEventListener("click", handleRoll);
|
rollBtn?.addEventListener("click", handleRoll);
|
||||||
addBtn?.addEventListener("click", addCombatant);
|
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
|
// Init
|
||||||
updateVisibility();
|
updateVisibility();
|
||||||
renderList();
|
renderList();
|
||||||
|
if (isPro) renderSessionList();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
---
|
---
|
||||||
|
export interface Props {
|
||||||
|
tier?: 'free' | 'pro';
|
||||||
|
userId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
import DmCard from './DmCard.astro';
|
import DmCard from './DmCard.astro';
|
||||||
import DmButton from './DmButton.astro';
|
import DmButton from './DmButton.astro';
|
||||||
import { dmTranslations as T } from '../../i18n/dm-translations';
|
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
|
<textarea
|
||||||
id="notes-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"
|
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 clearBtn = container.querySelector('[data-testid="notes-clear-btn"]') as HTMLButtonElement;
|
||||||
const copyBtn = container.querySelector('#notes-copy-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 debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let hideIndicatorTimer: 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 {
|
function formatTime(date: Date): string {
|
||||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSavedIndicator(message?: string) {
|
function showSavedIndicator(message?: string) {
|
||||||
const text = message || `Сохранено · ${formatTime(new Date())}`;
|
const text = message || `Сохранено \u00b7 ${formatTime(new Date())}`;
|
||||||
savedIndicator.textContent = text;
|
savedIndicator.textContent = text;
|
||||||
savedIndicator.classList.remove('opacity-0');
|
savedIndicator.classList.remove('opacity-0');
|
||||||
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
|
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
|
||||||
@@ -52,29 +65,128 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
|
|||||||
}, 1500);
|
}, 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() {
|
function updateCharCount() {
|
||||||
const count = textarea.value.length;
|
const count = textarea.value.length;
|
||||||
charCount.textContent = String(count);
|
charCount.textContent = String(count);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadSavedNotes() {
|
async function loadDbNotes(): Promise<{ id: number; content: string | null } | null> {
|
||||||
const saved = loadNotes();
|
try {
|
||||||
textarea.value = saved;
|
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();
|
updateCharCount();
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleInput() {
|
async function handleInput() {
|
||||||
updateCharCount();
|
updateCharCount();
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
debounceTimer = setTimeout(() => {
|
debounceTimer = setTimeout(async () => {
|
||||||
saveNotes(textarea.value);
|
if (isPro) {
|
||||||
showSavedIndicator();
|
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);
|
}, 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleClear() {
|
async function handleClear() {
|
||||||
textarea.value = '';
|
textarea.value = '';
|
||||||
clearNotes();
|
if (isPro && dbNoteId) {
|
||||||
|
const ok = await updateDbNote(dbNoteId, '');
|
||||||
|
if (!ok) {
|
||||||
|
clearNotes();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearNotes();
|
||||||
|
}
|
||||||
updateCharCount();
|
updateCharCount();
|
||||||
savedIndicator.classList.add('opacity-0');
|
savedIndicator.classList.add('opacity-0');
|
||||||
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
|
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
|
||||||
@@ -99,9 +211,12 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
|
|||||||
|
|
||||||
function handleStorageChange(e: StorageEvent) {
|
function handleStorageChange(e: StorageEvent) {
|
||||||
if (e.key === 'dm-notes' && e.newValue !== null) {
|
if (e.key === 'dm-notes' && e.newValue !== null) {
|
||||||
textarea.value = e.newValue;
|
// Only apply localStorage changes if not PRO (or if PRO but DB failed)
|
||||||
updateCharCount();
|
if (!isPro) {
|
||||||
showSavedIndicator('Обновлено');
|
textarea.value = e.newValue;
|
||||||
|
updateCharCount();
|
||||||
|
showSavedIndicator('Обновлено');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -171,6 +171,11 @@ import DmButton from "./DmButton.astro";
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Open5eUIManager } from "@/lib/client/open5e-ui";
|
import { Open5eUIManager } from "@/lib/client/open5e-ui";
|
||||||
|
import {
|
||||||
|
getCachedTranslation,
|
||||||
|
setCachedTranslation,
|
||||||
|
fetchTranslation,
|
||||||
|
} from "@/lib/client/translation";
|
||||||
|
|
||||||
const CR_OPTIONS = [
|
const CR_OPTIONS = [
|
||||||
{ value: "", label: "Все ОП" },
|
{ value: "", label: "Все ОП" },
|
||||||
@@ -230,39 +235,9 @@ import DmButton from "./DmButton.astro";
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TRANSLATION_CACHE_KEY = "o5-translations";
|
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: Record<string, unknown> | null): T {
|
||||||
|
|
||||||
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 {
|
|
||||||
if (!translated) return item;
|
if (!translated) return item;
|
||||||
return { ...item, ...translated };
|
return { ...item, ...translated } as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
|
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
|
||||||
@@ -564,15 +539,7 @@ import DmButton from "./DmButton.astro";
|
|||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/dm/translate?slug=${encodeURIComponent(slug)}&type=${type}`);
|
const translated = await fetchTranslation(slug, 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);
|
|
||||||
|
|
||||||
btn.textContent = "Переведено";
|
btn.textContent = "Переведено";
|
||||||
btn.disabled = true;
|
btn.disabled = true;
|
||||||
|
|||||||
@@ -89,6 +89,90 @@ export const dmTranslations = {
|
|||||||
aiSignInCta: "Войдите, чтобы использовать ИИ",
|
aiSignInCta: "Войдите, чтобы использовать ИИ",
|
||||||
aiPlaceholder: "Генератор ИИ появится здесь",
|
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
|
// Auth
|
||||||
loginVk: "Войти через VK",
|
loginVk: "Войти через VK",
|
||||||
loginYandex: "Войти через Яндекс",
|
loginYandex: "Войти через Яндекс",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
|
|||||||
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
||||||
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
||||||
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
||||||
|
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
|
||||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||||
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ if (user) {
|
|||||||
{T.initiative}
|
{T.initiative}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<InitiativeTracker />
|
<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- AI Generator -->
|
<!-- AI Generator -->
|
||||||
@@ -57,32 +58,7 @@ if (user) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
{Astro.locals.user ? (
|
{Astro.locals.user ? (
|
||||||
<DmCard>
|
<AiNpcForm />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<DmCard>
|
<DmCard>
|
||||||
<div class="p-6 text-center">
|
<div class="p-6 text-center">
|
||||||
@@ -123,7 +99,7 @@ if (user) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<DmCard>
|
<DmCard>
|
||||||
<NotesPanel />
|
<NotesPanel tier={user?.tier ?? 'free'} userId={user?.id} />
|
||||||
</DmCard>
|
</DmCard>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
|
|||||||
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
|
||||||
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
import Open5eReference from "@/components/dm/Open5eReference.astro";
|
||||||
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
import NotesPanel from "@/components/dm/NotesPanel.astro";
|
||||||
|
import AiNpcForm from "@/components/dm/AiNpcForm.astro";
|
||||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||||
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ if (user) {
|
|||||||
{T.initiative}
|
{T.initiative}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<InitiativeTracker />
|
<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- AI Generator -->
|
<!-- AI Generator -->
|
||||||
@@ -56,17 +57,15 @@ if (user) {
|
|||||||
{T.ai}
|
{T.ai}
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<DmCard>
|
{Astro.locals.user ? (
|
||||||
{Astro.locals.user ? (
|
<AiNpcForm />
|
||||||
<div class="p-6 text-center text-[var(--text-secondary)]">
|
) : (
|
||||||
<p>{T.aiPlaceholder}</p>
|
<DmCard>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div class="p-6 text-center">
|
<div class="p-6 text-center">
|
||||||
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
|
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</DmCard>
|
||||||
</DmCard>
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,7 +93,7 @@ if (user) {
|
|||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<DmCard>
|
<DmCard>
|
||||||
<NotesPanel />
|
<NotesPanel tier={user?.tier ?? 'free'} userId={user?.id} />
|
||||||
</DmCard>
|
</DmCard>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
.dm-theme {
|
.dm-theme {
|
||||||
/* Primary Accents */
|
/* Primary Accents — Orange for DM Dashboard */
|
||||||
--accent: #534AB7;
|
--accent: #E87722;
|
||||||
--accent-light: #6b5fc7;
|
--accent-light: #f08a3e;
|
||||||
--accent-dark: #3f3791;
|
--accent-dark: #c45f18;
|
||||||
--accent-hover: #6b5fc7;
|
--accent-hover: #f08a3e;
|
||||||
|
|
||||||
/* Secondary (Gold) */
|
/* Secondary (Gold) */
|
||||||
--gold: #c8a84b;
|
--gold: #c8a84b;
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
|
--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-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-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 */
|
||||||
--focus-ring: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
|
--focus-ring: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user