Compare commits

...
10 Commits
Author SHA1 Message Date
emilandClaude Opus 4.7 4f3e641458 feat(home): mark DM Dashboard promo with Work in Progress badge
Deploy / deploy (push) Canceled after 0s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:47:35 +03:00
emilandClaude Opus 4.7 815c5c7b04 fix(home): move prerender export inside frontmatter so it stops rendering as text
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 09:30:33 +03:00
emilandClaude Opus 4.7 79e1dc4144 fix(deploy): lock down postgres — no public port + password via .env
Yesterday's prod incident exposed postgres on 0.0.0.0:5432 with the
default 'postgres:postgres' credentials. A scanner ransomware bot
brute-forced it and dropped the database (left a readme_to_recover
note). We restored from a pre-incident dump and the user data is back,
but the underlying weakness was in this docker-compose.yml.

Changes:
- Remove `ports: "5432:5432"` from the postgres service entirely.
  Postgres is reachable only via the internal docker network. For
  ad-hoc admin access, use an SSH tunnel:
  `ssh -L 5432:localhost:5432 deploy@<host>`
- POSTGRES_PASSWORD now reads from `${POSTGRES_PASSWORD:-postgres}`
  via env interpolation. Prod `.env` (not in repo) provides the real
  value; local dev gets the `postgres` fallback so `docker compose up`
  still works without setup.
- Remove the no-longer-needed DATABASE_URL override in the app service
  `environment:` — `env_file: .env` already supplies it.

After this lands, the deploy pipeline will rsync the new compose.yml,
recreate containers with no exposed pg port, and substitute the strong
password from .env at container start. Volumes persist, data intact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:56:53 +03:00
emilandClaude Opus 4.7 cce582df6c feat(dm-dashboard): Wave 8 — spell generator + Open5e equipment/magicitems + UX polish
Wave 8 closes the high-value gaps from the original spec while staying
inside MVP scope (encounters/weapons/items still future work).

Spell generator (mirrors the NPC pattern end-to-end)
- src/lib/ai/types.ts: spellParamsSchema, spellResultSchema, Open5eSpell
- src/lib/ai/openrouter.ts: generateSpell using llama-3.3-70b:free for FREE
- src/lib/ai/kimi.ts: generateSpell using moonshot-v1-8k with json_schema
  response_format for PRO
- src/pages/api/dm/ai/generate-spell.ts: auth → rate-limit → optional
  /spells/ Open5e reference for balance → AI generate → Zod validate →
  increment counter. Spells are returned to client and not persisted
  (spec only requires NPC persistence for PRO).
- src/components/dm/AiSpellForm.astro: form (level, school, classes,
  tone, suggested name) + inline result card rendering with copy-JSON
- src/components/dm/AiKindSwitcher.astro: segmented NPC | Spell control
  wrapping both forms; default tab is NPC

Open5e Reference: equipment + magic items tabs
- src/lib/open5e/client.ts: searchEquipment, getEquipmentItem,
  searchMagicItems, getMagicItem (plus EquipmentItem / MagicItem types)
- src/lib/client/open5e-ui.ts: Tab type extended to four, search() and
  selectItem() dispatch all four
- src/components/dm/Open5eReference.astro: two more tab buttons; new
  renderEquipmentCard/Detail and renderMagicItemCard/Detail; filter
  dropdown hidden for new tabs (search-by-name only)
- src/lib/client/translation.ts: TranslationType union now includes
  "equipment" | "magicitem"
- src/lib/ai/openrouter.ts: translateOpen5eContent type widened (export
  Open5eContentType)
- src/pages/api/dm/translate.ts: ALLOWED_TYPES adds equipment/magicitem
  and dispatches to the right Open5e fetcher

UX polish
- src/components/dm/AiQuotaExhausted.astro: dedicated state shown to
  authenticated FREE users with remaining=0 — replaces the form with
  reset-time message and Boosty upgrade hint
- src/pages/{dm,ru/dm}/index.astro: render AiQuotaExhausted when the
  guard passes; otherwise render the kind switcher
- src/middleware/index.ts: remove [Middleware] debug console.logs that
  were noisy in prod logs (one line per request)

Verified locally: tsc 0 errors, lint 0 errors, vitest 339/339 passing,
npm run build succeeds without auth env vars.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 02:47:41 +03:00
emilandClaude Opus 4.7 e6dec1eb95 fix(deploy): apply drizzle migrations to prod on container startup
The deploy pipeline was building the app image with only `dist/` inside
and rsync'ing only `dist package*.json docker-compose.yml Dockerfile`
to the server. The drizzle migrations folder never reached prod, so
new schema changes (Wave 1 added `tier`, `boosty_verified_at`, and 5
tables) silently went missing. App requests then failed with
"column does not exist" errors at runtime.

Wave-1 CI step `npm run db:migrate` ran against the in-job postgres
service, not against prod — so it never helped.

Changes:
- src/db/migrate.ts -> src/db/migrate.mjs: plain JS so the prod image
  can run it via `node` without devDependencies (no tsx needed).
- Dockerfile: COPY drizzle and src/db/migrate.mjs into the image,
  prepend `node ./src/db/migrate.mjs &&` to the CMD. Container fails
  to start if migrations fail — better than serving with stale schema.
- .github/workflows/deploy.yml: rsync now also sends `drizzle` and
  `src` so the build context on the server has what the Dockerfile
  COPYs reference.
- package.json: `db:migrate` script switched to `node src/db/migrate.mjs`.
- eslint.config.mjs: enable node globals for the migrator script.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-16 00:29:32 +03:00
emilandClaude Opus 4.7 4b78e9edad fix(auth): defer env validation to first access so build works without secrets
`validateAuthEnv()` was running at module-load time, which fired during
`astro build` while constructing the route manifest — before any actual
request needed the secrets. CI build failed even though build-time code
doesn't use JWT_SECRET / OAuth secrets.

- src/lib/auth/env.ts: wrap authEnv in a Proxy that runs validation
  on first property read; cache the validated object after.
- src/lib/auth/jwt.ts: defer `new TextEncoder().encode(...)` of the
  secret behind a memoised getSecret() helper.
- src/lib/auth/oauth.ts: same for the JWT secret; vkOAuthConfig and
  yandexOAuthConfig switched to getter-based properties so credential
  access is also lazy.
- src/lib/auth/auth.test.ts: 3 tests previously asserted "throws at
  module load"; updated to assert "throws on first access" — semantic
  guarantee (invalid env throws) is preserved.

Runtime fail-fast is intact: any auth route reading authEnv.X will
throw with the same descriptive Zod error if a var is missing. Build
just no longer crashes when secrets aren't in env (e.g. CI deploy
pipeline running tsc/lint/build without secrets).

Verified: npm run build succeeds with no auth env vars set; tsc 0,
lint 0, vitest 339/339.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:45:03 +03:00
emilandClaude Opus 4.7 f2afaf60a3 fix(lint): unblock CI — add scoped eslint-disable for APIContext mocks
The 6 API/CORS test files mock the Astro APIContext by spreading
`{} as any` to satisfy fields not under test (cookies, redirect,
clientAddress, etc.). Replacing each cast individually would require
either full typed contexts (obscures tests) or 70+ inline disables.

Add one file-level `eslint-disable @typescript-eslint/no-explicit-any`
with a comment explaining the rationale to each affected file.

Production code remains lint-clean. Verified: tsc 0, lint 0,
vitest 339/339.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:39:50 +03:00
emilandClaude Opus 4.7 2f7453e571 chore(qa): Final Wave cleanup — resolve lint findings from F2 review
Production code (all findings cleared):
- GenerationHistory: drop unused fetchProHistory(userId) param,
  change let activeId to const
- InitiativeTracker: remove write-only currentSessionName state
- NotesPanel: remove unused isLoading flag
- Open5eReference: drop unused getCachedTranslation/setCachedTranslation
  imports; use fetchTranslation result directly instead of re-lookup
- ai/openrouter.ts: drop unused z + Monster imports; attach cause to
  rethrown AbortError
- ai/kimi.ts: drop unused z import
- api/dm/ai/generate.ts: annotate best-effort counter increment catch
- api/dm/ai/history.ts: drop unused sql import

Test files (trivial fixes; ~70 mock-related `as any` errors remain as
pre-existing tech debt across api test mocks):
- translation-client: drop unused MockBroadcastChannel _name param and
  unused getCachedTranslation destructure
- open5e-types: eslint-disable for the intentional type-test aliases
- {notes,initiative}-api: remove unused mockUser2 placeholder
- {notes,initiative,translate,history}-api: let mockDb -> const mockDb
- ai-generate-api: wrap deliberate `var` mock decls in eslint-disable
  with comment explaining vi.mock hoisting requirement

Verification: tsc 0 errors, vitest 339/339, lint 81 -> 70 errors
(production code fully clean; remaining errors are test-mock as-any
patterns from Waves 5-6).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:33:57 +03:00
emilandClaude Opus 4.7 701398f627 feat(dm-dashboard): Wave 7 — FREE→PRO data import flow (Task 22)
Adds an opt-in modal that prompts a PRO user with local data to migrate
their localStorage notes and sessionStorage initiative into the cloud.

- src/lib/client/import.ts: pure helpers (hasLocalData, shouldShowImport)
- src/components/dm/ImportModal.astro: modal UI + import orchestration
  with conflict handling for existing "DM Notes" record
- src/i18n/dm-translations.ts: 14 new keys for the import flow
- Wired into both EN and RU dm/index.astro
- tests/import-flow.test.ts: 18 unit tests on the helpers

Local storage is preserved as backup; only a "dm-import-asked" flag is
set after the user makes a decision (import or skip).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 23:33:39 +03:00
emil 09e947f430 feat(dm-dashboard): Wave 6 — NPC form, result card, history panel, notes/initiative DB migration, translation service 2026-05-15 23:09:06 +03:00
50 changed files with 4105 additions and 255 deletions
+3 -1
View File
@@ -50,9 +50,11 @@ jobs:
- name: Deploy Node.js app
run: |
# drizzle/ and src/db/migrate.mjs are needed inside the Docker
# build context so the image can apply pending migrations on startup.
rsync -avz --delete \
-e "ssh -i ~/.ssh/deploy_key" \
dist package*.json docker-compose.yml Dockerfile \
dist drizzle src package*.json docker-compose.yml Dockerfile \
${{ secrets.DEPLOY_USER }}@${{ secrets.DEPLOY_HOST }}:~/www/randify.pro/
- name: Restart app
+762 -2
View File
@@ -172,8 +172,8 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet
};
});
```
- The mock DB then implements a simple `evaluateCondition()` recursive evaluator that checks `type === "eq"` against item properties (converting snake_case column names to camelCase).
- This allows the mock to correctly filter by `userId` and `id`, making cross-user access tests reliable.
- The mock DB then implements a simple `evaluateCondition()` recursive evaluator that checks `type === "eq"` against item properties (converting snake_case column names to camelCase).
- This allows the mock to correctly filter by `userId` and `id`, making cross-user access tests reliable.
6. **Test coverage**
- `tests/notes-api.test.ts`: 19 tests covering auth 401s, CORS OPTIONS, GET list, POST create, POST validation errors, PUT update, PUT 404 (missing + cross-user), DELETE remove, DELETE 404 (missing + cross-user), missing/invalid id params.
@@ -382,3 +382,763 @@ Task: Generate and apply Drizzle migration for updated DB schema (Task 2 complet
- `npx tsc --noEmit`: 0 errors in project code (1 pre-existing parse error in `tests/history-api.test.ts`)
- `npm run build`: fails due to pre-existing auth env validation at build-time (`JWT_SECRET` missing) — unrelated to this change
---
# NPC Result Card Component — Planning Findings
## Date: 2026-05-15
### Existing Patterns Analyzed
**DmCard.astro** (`src/components/dm/DmCard.astro`):
- Base classes: `rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 hover:border-[var(--border-gold)] transition-all duration-[var(--transition-base)]`
- Padding variants: `none` (""), `sm` (p-4), `md` (p-5), `lg` (p-6)
- Props: `class?: string`, `padding?: "none" | "sm" | "md" | "lg"`, `dataTestid?: string`
- Use `padding="lg"` for the NPC card to match the spacious feel of DM cards.
**DmButton.astro** (`src/components/dm/DmButton.astro`):
- Variants: `primary` (accent bg, white text), `secondary` (card bg + border), `ghost`
- Sizes: `sm` (px-4 py-1.5 text-sm rounded-lg), `md` (px-6 py-2.5 text-base rounded-xl), `lg`
- For action buttons, use `variant="secondary" size="sm"` for "Copy JSON" and `variant="primary" size="sm"` for "Regenerate".
**DM Theme CSS (`src/styles/dm-theme.css`)**:
- Backgrounds: `--bg-primary: #16120e`, `--bg-card: #221e18`, `--bg-secondary: #1e1912`
- Text: `--text-primary: #f4f4f5`, `--text-secondary: #a1a1aa`, `--text-muted: #71717a`, `--text-cream: #e8dcc8`
- Borders: `--border-color: #3a3428`, `--border-gold: rgba(200,168,75,0.15)`, `--border-gold-strong: rgba(180,150,80,0.1)`
- Gold accents: `--gold: #c8a84b`, `--gold-light: #d4b76a`, `--gold-dark: #a88a3a`
**⚠️ CRITICAL: Theme Discrepancy — Purple vs Orange**
- `src/styles/dm-theme.css` sets `--accent: #534AB7` (purple), but AGENTS.md claims DM overrides to `#E87722` (orange).
- The task mandates: "Orange theme (#E87722), NO purple".
- **Recommendation**: Update `dm-theme.css` line 3 from `--accent: #534AB7` to `--accent: #E87722` and update `--accent-light`, `--accent-dark`, `--accent-hover` accordingly. This fixes the theme for ALL DM components at once (DmButton primary, focus rings, etc.). Then the NPC card can safely use `var(--accent)`.
- If modifying the theme CSS is out of scope, hardcode `#E87722` in the NPC card for all accent usage (stat values, story border, tags, buttons).
### NPCResult Schema (`src/lib/ai/types.ts`)
Available fields:
- `name: string`, `race: string`, `role: string`, `level: number`
- `hp: number`, `ac: number`, `cr: string`, `speed: string`
- `appearance: string`, `trait: string`, `motivation: string`, `secret: string`, `history: string`
**Missing fields for tags**: There is **no `tags`, `skills`, or `abilities` array** in `NPCResult`. The requirement asks for "Tags: skills, abilities (pill chips)".
- **Decision**: Derive pseudo-tags from `race` and `role` (display both as chips), OR omit the tags section entirely since the data model doesn't support it. Recommending the pseudo-tag approach for visual completeness.
### Sanitization Strategy
**Astro auto-escapes server-rendered expressions** — `{npc.name}`, `{npc.appearance}`, etc. are safe by default. No manual escaping needed in the `.astro` template.
**No `innerHTML` usage**: The card can be fully static markup. Interactivity (copy JSON, regenerate) uses `addEventListener` on existing DOM nodes. Copy JSON does `navigator.clipboard.writeText(JSON.stringify(npc))`. Regenerate calls the passed callback.
**No shared `escapeHtml` utility** exists in `src/lib/client/`. It is duplicated in 3 files. For this component, since it's pure Astro template with no dynamic HTML injection, `escapeHtml` is unnecessary. But if future iterations add client-side NPC rendering, consolidate `escapeHtml` into `src/lib/client/escape-html.ts`.
### i18n Gaps
`src/i18n/dm-translations.ts` has **no NPC-specific labels**. Must add:
```ts
// NPC labels
npcName: "Имя",
npcRace: "Раса",
npcRole: "Класс",
npcLevel: "Уровень",
npcHp: "ХП",
npcAc: "КЗ",
npcCr: "ОП",
npcSpeed: "Скорость",
npcAppearance: "Внешность",
npcTrait: "Черта",
npcMotivation: "Мотивация",
npcSecret: "Тайна",
npcHistory: "История",
npcCopyJson: "Копировать JSON",
npcCopied: "Скопировано",
npcRegenerate: "Перегенерировать",
```
### Clipboard Utility
`src/lib/client/clipboard.ts` exports `CopyFeedback` class for copy → checkmark icon swap animation. Can be used for the "Copy JSON" button if implementing a visual feedback state.
### Recommended Component Structure
```astro
---
import DmCard from "./DmCard.astro";
import DmButton from "./DmButton.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import type { NPCResult } from "@/lib/ai/types";
export interface Props {
npc: NPCResult;
onRegenerate?: () => void;
}
const { npc, onRegenerate } = Astro.props;
---
<DmCard padding="lg" dataTestid="ai-npc-result">
<!-- Header: Name + Meta + Actions -->
<div class="flex items-start justify-between gap-4 mb-5">
<div class="min-w-0">
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">{npc.name}</h3>
<p class="text-sm text-[var(--text-secondary)] mt-1">
{npc.race} · {npc.role} · Ур. {npc.level} · ОП {npc.cr}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<DmButton variant="secondary" size="sm" id="npc-copy-btn" dataTestid="npc-copy-btn">
{T.npcCopyJson}
</DmButton>
{onRegenerate && (
<DmButton variant="primary" size="sm" id="npc-regen-btn" dataTestid="npc-regen-btn">
{T.npcRegenerate}
</DmButton>
)}
</div>
</div>
<!-- Stats Row: HP, AC, Speed, CR (4-col grid) -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<StatBox label={T.npcHp} value={String(npc.hp)} />
<StatBox label={T.npcAc} value={String(npc.ac)} />
<StatBox label={T.npcSpeed} value={npc.speed} />
<StatBox label={T.npcCr} value={npc.cr} />
</div>
<!-- Attributes: Appearance, Trait, Motivation, Secret -->
<div class="space-y-3 mb-5">
<AttributeRow label={T.npcAppearance} value={npc.appearance} />
<AttributeRow label={T.npcTrait} value={npc.trait} />
<AttributeRow label={T.npcMotivation} value={npc.motivation} />
<AttributeRow label={T.npcSecret} value={npc.secret} />
</div>
<!-- Story Block: History/Backstory -->
<div class="border-l-4 border-[#E87722] pl-4 py-2 italic text-[var(--text-cream)] bg-[var(--bg-secondary)]/50 rounded-r-lg">
<p class="text-sm leading-relaxed">{npc.history}</p>
</div>
<!-- Tags (pseudo-tags from race + role) -->
<div class="flex flex-wrap gap-2 mt-5">
<span class="px-3 py-1 text-xs font-medium rounded-full bg-[#E87722]/10 text-[#E87722] border border-[#E87722]/20">
{npc.race}
</span>
<span class="px-3 py-1 text-xs font-medium rounded-full bg-[#E87722]/10 text-[#E87722] border border-[#E87722]/20">
{npc.role}
</span>
</div>
</DmCard>
<script>
// Client-side: copy JSON + regenerate handler
document.addEventListener("DOMContentLoaded", () => {
const copyBtn = document.getElementById("npc-copy-btn");
const regenBtn = document.getElementById("npc-regen-btn");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
const card = copyBtn.closest('[data-testid="ai-npc-result"]');
const npcData = card?.getAttribute("data-npc");
if (!npcData) return;
try {
await navigator.clipboard.writeText(npcData);
const originalText = copyBtn.textContent;
copyBtn.textContent = "Скопировано";
setTimeout(() => { copyBtn.textContent = originalText; }, 1500);
} catch {
/* ignore clipboard errors */
}
});
}
if (regenBtn) {
regenBtn.addEventListener("click", () => {
regenBtn.dispatchEvent(new CustomEvent("npc-regenerate", { bubbles: true }));
});
}
});
</script>
```
**Note on data passing to client script**: Astro components cannot directly pass objects to `<script>` blocks. Options:
1. Serialize NPC as `data-npc` JSON attribute on the card container (`data-npc={JSON.stringify(npc)}`). The script reads it via `getAttribute` + `JSON.parse`. This is safe since Astro auto-escapes attribute values.
2. Have the parent page manage the copy/regenerate logic and dispatch custom events.
3. Use `is:inline` script with `define:vars` (Astro feature) to pass the npc object directly to the script.
**Recommended**: Use `define:vars` with `is:inline` script for clean data passing without DOM attribute parsing.
### Checklist for Implementation
- [ ] Add NPC i18n keys to `src/i18n/dm-translations.ts`
- [ ] Fix `dm-theme.css` `--accent` to `#E87722` (or hardcode orange in component)
- [ ] Create `src/components/dm/AiNpcResult.astro`
- [ ] Props: `npc: NPCResult`, `onRegenerate?: () => void`
- [ ] Use `DmCard` with `padding="lg"`
- [ ] Header: Name (bold, truncate), meta line (race · role · level · CR), Copy + Regenerate buttons
- [ ] Stats: 4-col grid (HP, AC, Speed, CR) with `bg-[var(--bg-secondary)]` boxes
- [ ] Attributes: 4 rows with label (muted, uppercase) + value
- [ ] Story block: left orange border (`border-l-4 border-[#E87722]`), italic, cream text, subtle bg
- [ ] Tags: pill chips for race + role (or omit if out of scope)
- [ ] Copy JSON: `navigator.clipboard.writeText(JSON.stringify(npc))` with brief "Скопировано" feedback
- [ ] Regenerate: dispatch custom event or call callback
- [ ] Zero `innerHTML` usage — pure Astro template + event listeners
- [ ] No favorite button
- [ ] Add `dataTestid` attributes for testing
---
# InitiativeTracker DB Persistence Migration — Learnings
## Date: 2026-05-15
### Context
Task: Migrate InitiativeTracker to use DB persistence for PRO users, keep sessionStorage for FREE.
### Current State Analysis
1. **`src/components/dm/InitiativeTracker.astro`** — Pure client-side component (418 lines).
- Uses `sessionStorage` key `"dm-initiative"` with legacy `"it-combatants"` fallback migration.
- `activeIndex` is a module-level variable — never persisted to any storage.
- No Astro props accepted. Duplicated inline `escapeHtml()` (pre-existing anti-pattern).
2. **`src/pages/api/dm/initiative.ts`** — Full CRUD API already exists.
- `GET` returns `[{ id, name, participants, createdAt, updatedAt }]` for authenticated user.
- `POST` creates session with `{ name, participants? }`.
- `PUT` updates by `?id=` with `{ name?, participants? }`.
- `DELETE` removes by `?id=`.
- Auth via `locals.user`; CORS via `jsonResponse()` helper.
3. **DB Schema** (`initiative_sessions`): `id`, `userId`, `name`, `participants` (JSONB), `createdAt`, `updatedAt`.
4. **Data model compatibility**: Client `CombatantData = { id, name, initiative, hp? }` maps cleanly into API `participantSchema`. The schema is **not** `.strict()`, so extra fields are stripped and optional fields are tolerated. Sending `{ id, name, initiative, hp }` from client is fully valid.
5. **Parent pages**: `src/pages/dm/index.astro` and `src/pages/ru/dm/index.astro` use `<InitiativeTracker />` with no props. `Astro.locals.user` provides `tier` and `id`.
### Implementation Plan
#### Props
```astro
export interface Props {
tier?: 'free' | 'pro';
userId?: number;
}
```
Parent pages pass: `<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />`
#### Server-to-Client Bridge
Add `data-tier={tier}` and `data-user-id={userId ?? ''}` to root `<div>`. Client script reads:
```ts
const tier = (trackerEl?.dataset.tier as 'free' | 'pro') || 'free';
const userId = trackerEl?.dataset.userId ? parseInt(trackerEl.dataset.userId, 10) : undefined;
const isPro = tier === 'pro' && !!userId;
```
#### PRO-Only UI Panel (Astro template, conditional `{tier === 'pro'}`)
Rendered above add-combatant form inside a `DmCard`:
- `DmInput id="it-session-name"` — session name input.
- `DmButton id="it-save-session-btn"` — manual save trigger.
- `DmButton id="it-new-session-btn"` — clears current session state.
- `#it-session-list` — scrollable list of saved sessions (name + date + delete button).
- `#it-session-status` — transient success/error message.
#### New Translation Keys Needed
- `saveSession`, `newSession`, `sessionNamePlaceholder`, `noSavedSessions`, `sessionSaved`, `saveError`, `loadSessionError`, `deleteSessionConfirm`.
#### Client Script Additions
**State**:
```ts
let currentSessionId: number | null = null;
let currentSessionName = '';
```
**API wrappers**:
- `apiFetchSessions()` → GET /api/dm/initiative
- `apiSaveSession(name, participants, id?)` → POST (new) or PUT (update existing)
- `apiDeleteSession(id)` → DELETE /api/dm/initiative?id=X
**Core functions**:
- `renderSessionList()` — async fetch, renders rows with click-to-load and delete. Highlights current session.
- `loadDbSession(id)` — fetches sessions, maps `participants` → `CombatantData[]`, calls `saveCombatants()` to sessionStorage (graceful fallback), resets `activeIndex = 0`, re-renders.
- `handleSaveSession()` — reads name input (defaults to timestamp), POST or PUT, updates `currentSessionId`, shows status, refreshes list. On error: shows red status, **does not touch sessionStorage**.
- `handleNewSession()` — clears `currentSessionId/name/input`, calls `clearAll()`.
- `deleteDbSession(id)` — confirm dialog, DELETE, re-renders list.
**Event listeners** (attached defensively with `?.`):
```ts
document.getElementById('it-save-session-btn')?.addEventListener('click', handleSaveSession);
document.getElementById('it-new-session-btn')?.addEventListener('click', handleNewSession);
```
**Init**:
```ts
updateVisibility();
renderList();
if (isPro) renderSessionList();
```
### Key Design Decisions
| Decision | Rationale |
|----------|-----------|
| **Manual save only** | Requirement explicitly forbids auto-save. |
| **sessionStorage always kept** | `saveCombatants()` still writes to `dm-initiative`. On API failure, user loses nothing. On PRO save, sessionStorage is **not** deleted. |
| **activeIndex never in API payload** | It stays a pure in-memory var. Loading a DB session always resets it to 0. |
| **Data attributes for props** | Cleanest bridge for a module `<script>` without inlining vars via `define:vars`. |
| **Zod compatibility** | API schema is not `.strict()`. Client sends minimal payload; server accepts silently. |
| **Session name required** | API `createSessionSchema` requires `name`. Client defaults to `"Сессия {localeDate}"` if empty. |
| **Escape user content** | Session names rendered via existing `escapeHtml()` to prevent XSS in `innerHTML`. |
| **Missing userId fallback** | If `tier === 'pro'` but `userId` is missing, `isPro = false` and behavior falls back to FREE. |
### Files to Modify
1. `src/components/dm/InitiativeTracker.astro` — add props, conditional PRO UI, API integration, session management.
2. `src/i18n/dm-translations.ts` — add 8 new keys.
3. `src/pages/dm/index.astro` — pass `tier` and `userId` props.
4. `src/pages/ru/dm/index.astro` — mirror prop changes.
### Verification Steps
- `npx tsc --noEmit` — zero errors.
- `npx vitest run` — full suite passes (271+ tests).
- Manual QA:
- FREE user: sessionStorage works exactly as before.
- PRO user: add combatants → Save → reload → Load → combatants restored.
- PRO user: `activeIndex` resets to 0 on every session load.
- PRO user: offline/API failure → error shown, sessionStorage intact.
### Pre-existing Bug Note
`src/pages/dm/index.astro` contains a duplicated AI section (lines 80–98 mirror 59–77). Out of scope for this task but should be cleaned up separately.
---
# Task 22: FREE → PRO Import Flow — Learnings
## Date: 2026-05-15
### What Was Done
1. **Pure helpers in `src/lib/client/import.ts`** — `getLocalNotes()`, `getLocalInitiative()` (with `it-combatants` legacy fallback), `hasLocalData()`, `wasAsked()`, `markAsked()`, `shouldShowImport(tier, userId)`. Pure, testable, no DOM coupling.
2. **`src/components/dm/ImportModal.astro`** — modal renders only when `shouldShowImport()` returns true on mount. Trigger condition: `tier === 'pro'` && `userId` present && `hasLocalData()` && `!wasAsked()`. Spec says "on tier upgrade" — interpreted as "first PRO page load with local data, not yet asked".
3. **Conflict handling** — only for notes (initiative API allows multiple sessions, so import always creates a new session named `"Импорт {date}"`). If an existing note titled "DM Notes" has non-empty content, prompt Keep both / Replace / Skip. Keep both creates a second note titled "DM Notes (импорт)".
4. **localStorage / sessionStorage preserved** — per Task 22 "Must NOT do". Even on successful import, local copies are kept as backup; only the `dm-import-asked` flag is set.
5. **i18n keys** — 14 new keys added to `src/i18n/dm-translations.ts` (`importTitle`, `importPrompt`, `importBtn`, `importSkip`, etc.).
6. **Wired into both EN and RU dm/index.astro** with `<ImportModal tier={user?.tier ?? 'free'} userId={user?.id} />`.
7. **Unit tests** — `tests/import-flow.test.ts`, 18 tests covering all branches of the helpers + decision matrix.
### Key Findings
- **Detecting tier upgrade is hard without server-side event**: The plan says "show on tier upgrade event". Without a server-side notification channel, the practical proxy is "first PRO page load with local data that hasn't been asked yet". The `dm-import-asked` flag in localStorage suppresses re-asking.
- **API parity**: Notes have one "DM Notes" canonical record per user (NotesPanel convention) — import upserts that. Initiative API allows multiple sessions, so import just POSTs a new one.
- **Don't pre-fetch on every PRO load**: The decision `shouldShowImport` is cheap (local storage reads), but the existence check against the DB (for note conflict) only happens AFTER the user clicks "Import" — keeps the page load lean.
### Verification
- `npx tsc --noEmit`: 0 errors
- `npx vitest run tests/import-flow.test.ts`: 18/18 passed
- `npx vitest run` (full suite): 339/339 passed in 25 test files
---
# Final Wave Cleanup — Learnings
## Date: 2026-05-15
### Verdicts
- **F1 Plan Compliance Audit (oracle)**: APPROVE — 10/10 Must Have, 10/10 Must NOT Have clean, 24/24 tasks marked complete.
- **F2 Code Quality Review**: initially REJECT WITH ISSUES — 98 lint errors. Fixed all 13 production-code findings; brought total to 70 (test-only).
- **F3 Manual QA**: skipped — required Playwright + running dev server; build is blocked at this env without `JWT_SECRET` etc.
- **F4 Scope Fidelity**: APPROVE — minor non-blocking flags: `test-results/` Playwright artifacts committed in Wave 2 (should be gitignored); `dm-theme.css` purple → orange swap in Wave 6 not bound to a specific task line item but aligned with guardrails.
### Production-Code Lint Fixes
- `GenerationHistory.astro` — dropped unused `userId` param from `fetchProHistory`; changed `let activeId` to `const`.
- `InitiativeTracker.astro` — removed write-only `currentSessionName` and its 4 assignments; sessionNameInput is the visible source of truth.
- `NotesPanel.astro` — removed unused `isLoading` flag.
- `Open5eReference.astro` — dropped unused `setCachedTranslation` and `getCachedTranslation` imports; used `translated` from `fetchTranslation` directly (eliminated wasteful re-lookup).
- `src/lib/ai/openrouter.ts` — removed unused `z` and `Monster` imports; added `{ cause: err }` to AbortError rethrows.
- `src/lib/ai/kimi.ts` — removed unused `z` import.
- `src/pages/api/dm/ai/generate.ts` — annotated the empty `catch` block: counter increment is best-effort.
- `src/pages/api/dm/ai/history.ts` — removed unused `sql` import.
### Remaining Lint Tech Debt (70 errors, all in test files)
70 `@typescript-eslint/no-explicit-any` errors in test mocks for Drizzle ORM query chains (`tests/notes-api.test.ts`, `initiative-api.test.ts`, `translate-api.test.ts`, `history-api.test.ts`, `ai-generate-api.test.ts`, `cors.test.ts`). Replacing these with proper types requires modeling Drizzle's complex chained SQL builder shape — out of scope for the final verification wave. These were committed across Waves 5–6 by prior agent runs and should be addressed in a dedicated test-refactor sweep.
### Other Cleanups
- `tests/translation-client.test.ts` — dropped unused `_name` param from `MockBroadcastChannel`; removed unused destructured `getCachedTranslation`.
- `tests/open5e-types.test.ts` — added eslint-disable comments for the intentional type-test aliases.
- `tests/{initiative,notes}-api.test.ts` — removed unused `mockUser2` placeholder objects.
- `tests/{notes,initiative,translate,history}-api.test.ts` — changed `let mockDb` to `const mockDb` (never reassigned).
- `tests/ai-generate-api.test.ts` — wrapped the `var` mock declarations in `eslint-disable no-var`; documented that vi.mock hoisting requires `var`.
### Final State
- tsc: 0 errors
- vitest: 339/339 pass (25 files)
- lint: 70 errors (all test-mock `as any`, all pre-existing from Waves 5–6)
- build: still blocked by missing JWT_SECRET / OAuth env at build time (pre-existing, documented in Wave 1 learnings)
---
# FREE → PRO Data Import Flow — Planning Findings
## Date: 2026-05-15
### Context
Task: Build an import modal that migrates FREE-tier localStorage/sessionStorage data into PRO-tier DB when a user upgrades.
### Existing Patterns Analyzed
**1. Modal / Overlay Pattern (`Open5eReference.astro`)**
- Container: `fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center p-4 pt-12`
- Background click-to-close: absolute `inset-0` div with click listener
- Content card: `DmCard padding="lg"` with `w-full max-w-2xl max-h-[80vh] overflow-y-auto relative border-2 border-[var(--accent)] z-10`
- Close button: absolute top-right with SVG × icon, `aria-label="Закрыть"`, `focus-visible:ring-2 focus-visible:ring-[var(--accent)]`
- ARIA: `role="dialog" aria-modal="true"`
**2. DmButton.astro Variants**
- `primary` = `bg-[var(--accent)] text-white` — orange theme, use for main action (Import)
- `secondary` = card bg + border — use for secondary actions
- `ghost` = transparent — use for dismiss (Skip)
- Sizes: `sm` for compact modal actions
**3. DmCard.astro**
- Base: `rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg`
- Padding variants: `none`, `sm` (p-4), `md` (p-5), `lg` (p-6)
- Use `padding="lg"` for the modal content card
**4. Theme System (`src/styles/dm-theme.css`)**
- `--accent: #E87722` is already correctly set in `.dm-theme`
- Using `var(--accent)` anywhere inside `.dm-theme` scope yields orange
- NO purple anywhere — `var(--accent)` is safe
**5. Data Storage Locations**
- **Notes**: `localStorage` key `dm-notes` — plain string (note content)
- **Initiative**: `sessionStorage` key `dm-initiative` — JSON array of `CombatantData[]` with shape `{ id, name, initiative, hp?, maxHp?, modifier?, ac?, isPlayer?, active? }`
- **Legacy initiative fallback**: `sessionStorage` key `it-combatants` — older format, already migrated by `InitiativeTracker.astro` on load
**6. API Routes**
- `GET /api/dm/notes` → returns `Array<{ id, title, content, createdAt, updatedAt }>` for authenticated user
- `POST /api/dm/notes` → body `{ title: string, content?: string }` → creates note, returns `{ id, title, content, createdAt, updatedAt }`
- `PUT /api/dm/notes?id=X` → body `{ title?, content? }` → updates existing note
- `GET /api/dm/initiative` → returns `Array<{ id, name, participants, createdAt, updatedAt }>` for authenticated user
- `POST /api/dm/initiative` → body `{ name: string, participants?: Array<Participant> }` → creates session
- `PUT /api/dm/initiative?id=X` → body `{ name?, participants? }` → updates existing session
- All routes require auth (`locals.user`), return 401 if unauthenticated
- CORS headers handled by `jsonResponse()` helper
**7. NotesPanel DB Integration Pattern**
- NotesPanel looks for note with `title === 'DM Notes'` or falls back to `notes[0]`
- When loading DB notes, if none exists, it auto-creates an empty note via POST
- This means a PRO user who has visited the notes tab may already have a blank "DM Notes" entry in DB
- **Implication for import**: a blank note (content null/empty) should probably be treated as "no existing data" for conflict purposes, OR we should replace it. The import modal should check `content?.trim()` length.
**8. Tier & Auth Flow**
- Middleware attaches `Astro.locals.user` with `tier: 'free' | 'pro'`
- `src/pages/dm/index.astro` passes `tier={user?.tier ?? 'free'} userId={user?.id}` to child components
- There is **no server-side mechanism** to detect a tier upgrade (server can't read localStorage)
- Tier change detection must be client-side: store previous tier in `localStorage` (`dm-tier`), compare on page load
---
### Implementation Plan
#### New File: `src/components/dm/ImportModal.astro`
**Props:**
```astro
export interface Props {
tier?: 'free' | 'pro';
}
```
**Aero Template Structure:**
```astro
<div
id="dm-import-modal"
class="hidden fixed inset-0 z-50 bg-black/60 backdrop-blur-sm flex items-start justify-center p-4 pt-12"
role="dialog"
aria-modal="true"
data-tier={tier}
data-testid="import-modal"
>
<!-- Click-outside backdrop -->
<div id="dm-import-overlay" class="absolute inset-0" data-testid="import-modal-overlay"></div>
<DmCard padding="lg" class="w-full max-w-lg relative z-10 border-2 border-[var(--accent)]">
<!-- Header -->
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-bold text-[var(--text-primary)]">{T.importTitle}</h2>
<button id="dm-import-close" type="button" ... aria-label={T.actionClose}>
<svg>...</svg>
</button>
</div>
<!-- Dynamic content container -->
<div id="dm-import-content" class="space-y-4"></div>
<!-- Action buttons -->
<div id="dm-import-actions" class="flex items-center justify-end gap-2 mt-6">
<DmButton id="dm-import-skip" variant="ghost" size="sm">{T.importSkip}</DmButton>
<DmButton id="dm-import-start" variant="primary" size="sm">{T.importStart}</DmButton>
</div>
</DmCard>
</div>
```
**Client Script Logic (state machine):**
```ts
enum State {
DETECTING = 'detecting', // checking local/session storage + DB preflight
REVIEW = 'review', // show found data + conflict options
IMPORTING = 'importing', // show progress per step
COMPLETE = 'complete', // success, dispatch event
ERROR = 'error', // show error + retry
NO_DATA = 'no-data', // nothing to import, auto-dismiss
}
```
**Flow:**
1. **Mount / Auto-detect:**
- Read `container.dataset.tier` as current tier
- Read `localStorage.getItem('dm-tier')` as previous tier
- If `prevTier !== 'free' || currentTier !== 'pro'` → do nothing (not an upgrade event)
- If upgrade detected:
- Read `localStorage.getItem('dm-notes')` → `hasNotes = !!value && value.trim().length > 0`
- Read `sessionStorage.getItem('dm-initiative')` → `hasInitiative = !!value && JSON.parse(value).length > 0`
- If `!hasNotes && !hasInitiative` → show NO_DATA state, auto-dismiss after 2s
- Else → proceed to DETECTING
2. **DETECTING state:**
- If `hasNotes`: `fetch('/api/dm/notes')` to check for existing DB notes
- If `hasInitiative`: `fetch('/api/dm/initiative')` to check for existing DB sessions
- Store preflight results: `existingNotes: Note[]`, `existingSessions: Session[]`
- Transition to REVIEW
3. **REVIEW state:**
- Show bullet list of found items:
- "Найдены заметки" (if hasNotes)
- "Найдена сессия инициативы" (if hasInitiative)
- For each item where DB already has data, show conflict radio group:
- Label: "В базе уже есть [заметки / сессии]. Что делать?"
- Options (default selected: "keep-both"):
- `keep-both`: "Сохранить оба" — create NEW DB entry with imported data
- `replace`: "Заменить" — update the most recent existing DB entry
- `skip`: "Пропустить" — do not import this data type
- Buttons: "Импортировать" (primary) / "Пропустить" (ghost)
4. **IMPORTING state:**
- Hide action buttons, show progress list
- For each data type in order (notes → initiative):
- Show "Импорт [типа]..." with spinner
- Execute POST/PUT
- On success: update to "[Тип] импортирован ✓"
- After all done → transition to COMPLETE
5. **COMPLETE state:**
- Show "Импорт завершён! ✓"
- Show summary: "Заметки: импортированы, Сессия: импортирована"
- Single button: "Закрыть" (primary)
- On close: dispatch `CustomEvent('import-complete', { bubbles: true })`
6. **ERROR state:**
- Show which step failed + error message from response
- Buttons: "Повторить" (primary) / "Пропустить" (ghost)
- Retry re-attempts only the failed step(s)
7. **Dismissal (Skip / Close / Backdrop click / Escape):**
- Hide modal with `classList.add('hidden')`
- Dispatch `CustomEvent('import-dismissed', { bubbles: true })`
- Do NOT delete localStorage/sessionStorage
- Store a flag `localStorage.setItem('dm-import-dismissed', '1')` to prevent re-prompting on subsequent page loads during the same session? (optional — task says "only on tier upgrade event", so on next login the prevTier will already be 'pro', so it won't trigger again. But if user refreshes the page immediately after dismissing, the prevTier in localStorage is still 'free' because we haven't updated it yet.)
- **Important**: Update `localStorage.setItem('dm-tier', 'pro')` immediately upon showing the modal, OR upon any dismissal/completion, so that a refresh doesn't re-trigger. Recommendation: update `dm-tier` to current tier as soon as the modal opens.
**Conflict Resolution Details:**
| Data Type | Keep Both | Replace | Skip |
|-----------|-----------|---------|------|
| **Notes** | POST new note with `title: 'Заметки (импорт)'`, `content: localStorage notes` | PUT the most recent existing note (`existingNotes[existingNotes.length-1].id`) with `content: localStorage notes` | No API call |
| **Initiative** | POST new session with `name: 'Импортированная сессия'`, `participants: sessionStorage combatants` | PUT the most recent existing session (`existingSessions[existingSessions.length-1].id`) with `name: 'Импортированная сессия'`, `participants: sessionStorage combatants` | No API call |
**Note on "Replace" for notes**: NotesPanel looks for `title === 'DM Notes'`. If the existing note has a different title and we PUT its content, NotesPanel will still find it (falls back to `notes[0]`). If the user already has a "DM Notes" entry, replacing it updates that exact note. This is the desired behavior.
**API Error Handling:**
- `401 Unauthorized` → shouldn't happen for a PRO user, but show "Сессия устарела. Обновите страницу."
- `400 Validation failed` → shouldn't happen with well-formed data, but show generic error
- `fetch` throws (network) → show "Ошибка соединения. Проверьте интернет."
- Any error → transition to ERROR state with specific message
**Keyboard Accessibility:**
- `Escape` key closes/dismisses modal
- `Tab` traps focus inside modal while open (optional but recommended)
- Close button and backdrop click both dismiss
**Data Attributes for Testing:**
- `data-testid="import-modal"`
- `data-testid="import-modal-overlay"`
- `data-testid="import-content"`
- `data-testid="import-actions"`
- `data-testid="import-start-btn"`
- `data-testid="import-skip-btn"`
- `data-testid="import-close-btn"`
- `data-testid="import-retry-btn"`
- `data-state` on content container reflecting current state
---
#### Modified File: `src/i18n/dm-translations.ts`
Add the following keys (all Russian, RU-only DM Dashboard):
```ts
// Import Modal
importTitle: "Импорт данных",
importDescription: "Обнаружены данные из бесплатной версии. Хотите импортировать их в PRO?",
importNotesFound: "Найдены заметки",
importInitiativeFound: "Найдена сессия инициативы",
importNoData: "Нет данных для импорта",
importNoDataDesc: "Ваши локальные данные уже синхронизированы или отсутствуют.",
importStart: "Импортировать",
importSkip: "Пропустить",
importClose: "Закрыть",
importInProgressNotes: "Импорт заметок...",
importInProgressInitiative: "Импорт сессии инициативы...",
importDoneNotes: "Заметки импортированы",
importDoneInitiative: "Сессия инициативы импортирована",
importComplete: "Импорт завершён!",
importCompleteDesc: "Все данные успешно перенесены в PRO.",
importError: "Ошибка импорта",
importErrorDesc: "Не удалось импортировать: {item}. Попробуйте снова.",
importRetry: "Повторить",
importConflictTitle: "В базе уже есть данные",
importConflictNotesLabel: "Заметки:",
importConflictInitiativeLabel: "Инициатива:",
importKeepBoth: "Сохранить оба",
importReplace: "Заменить",
importSkipItem: "Пропустить",
```
---
#### Modified Files: `src/pages/dm/index.astro` + `src/pages/ru/dm/index.astro`
1. Import the component:
```astro
import ImportModal from "@/components/dm/ImportModal.astro";
```
2. Add the modal inside `DmLayout` (best placement: at the end of the layout, outside slots, so it overlays everything):
```astro
<ImportModal tier={user?.tier ?? 'free'} />
```
3. No additional client script needed in the parent — `ImportModal` handles its own tier-change detection internally.
**RU page note**: `src/pages/ru/dm/index.astro` is structurally identical. Mirror all changes exactly.
---
#### State Transition Diagram
```
MOUNT
│
▼
[Check prevTier vs currentTier]
│
├─ Not free→pro ──→ HIDDEN (do nothing)
│
└─ free→pro ──→ [Check local/session storage]
│
├─ No data ──→ NO_DATA state ──→ auto-dismiss after 2s
│ │
│ ▼
│ dispatch 'import-dismissed'
│
└─ Has data ──→ DETECTING state
│
▼
[GET /api/dm/notes]
[GET /api/dm/initiative]
│
▼
REVIEW state
│
┌───────────────┼───────────────┐
▼ ▼ ▼
User clicks User clicks User clicks
"Import" "Skip" backdrop/Escape
│ │ │
▼ ▼ ▼
IMPORTING dispatch dispatch
│ 'import-dismissed' 'import-dismissed'
▼
[POST/PUT per step]
│
┌─────────┴─────────┐
▼ ▼
All success Any error
│ │
▼ ▼
COMPLETE state ERROR state
│ │
▼ ▼
dispatch 'import-complete' User clicks
"Retry" ──→ IMPORTING
"Skip" ───→ dispatch 'import-dismissed'
```
---
### Edge Cases & Decisions
| Edge Case | Decision |
|-----------|----------|
| User is FREE (no upgrade) | Modal never shows. `dm-tier` stays 'free' or undefined. |
| User upgrades but has no local data | Show "No data to import" for 2s, then auto-dismiss. |
| User upgrades, has data, but DB is empty | Skip conflict UI, go straight to IMPORTING. |
| User upgrades, has data, DB has blank note | Treat blank note (content null/empty/whitespace) as no conflict. Still replace if user chooses replace. |
| API fails mid-import | Transition to ERROR state. Retry re-attempts only failed steps. Already-successful steps are not re-run. |
| User refreshes page during IMPORTING | State is lost. On re-mount, `dm-tier` is now 'pro' (updated on modal open), so no re-trigger. Partial imports remain in DB. |
| User clicks backdrop or Escape | Same as "Skip": dispatch 'import-dismissed', hide modal. |
| `sessionStorage` lost (new tab) | `sessionStorage` is tab-scoped. If user upgrades in a new tab, `dm-initiative` may not be present. This is acceptable — we can only import what's in the current tab's sessionStorage. |
| Legacy `it-combatants` in sessionStorage | `InitiativeTracker.astro` auto-migrates `it-combatants` → `dm-initiative` on load. Since the modal runs after page load, `dm-initiative` will already contain the migrated data. No special handling needed. |
---
### Files to Create / Modify
| File | Action | Lines (est.) |
|------|--------|--------------|
| `src/components/dm/ImportModal.astro` | **Create** — full component | ~350 |
| `src/i18n/dm-translations.ts` | **Modify** — append ~20 new keys | +20 lines |
| `src/pages/dm/index.astro` | **Modify** — import + render `<ImportModal />` | +2 lines |
| `src/pages/ru/dm/index.astro` | **Modify** — mirror EN page changes | +2 lines |
---
### Verification Plan
1. **TypeScript**: `npx tsc --noEmit` — zero errors
2. **Unit tests**: `npx vitest run` — full suite passes
3. **Manual QA scenarios**:
- FREE user with notes + initiative → visits page → no modal
- FREE user → upgrades to PRO → visits page → modal appears with found items
- PRO user with no local data → modal shows "No data to import" → auto-dismisses
- PRO user with data, empty DB → clicks Import → progress shown → success → 'import-complete' dispatched
- PRO user with data, DB has data → conflict options shown → "Replace" → PUT updates existing → success
- PRO user with data, DB has data → "Keep both" → POST creates new → success
- PRO user with data, DB has data → "Skip" → no API calls → 'import-dismissed' dispatched
- Network failure during import → error shown → Retry → succeeds
- localStorage `dm-notes` and sessionStorage `dm-initiative` still present after import
4. **Accessibility**: Tab focus stays inside modal; Escape dismisses; aria-modal="true" present
---
+7 -7
View File
@@ -1198,7 +1198,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add AI tab to DM navigation`
- Files: `src/components/dm/DmTabs.astro`, `src/components/dm/DmSidebar.astro`, `src/pages/dm/index.astro`, `src/i18n/dm-translations.ts`
- [ ] **16. Build NPC Generator Form Component**
- [x] **16. Build NPC Generator Form Component**
**What to do**:
- Create `src/components/dm/AiNpcForm.astro`: Form for NPC generation parameters.
@@ -1274,7 +1274,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add NPC generator form component`
- Files: `src/components/dm/AiNpcForm.astro`
- [ ] **17. Build NPC Result Card Component**
- [x] **17. Build NPC Result Card Component**
**What to do**:
- Create `src/components/dm/AiNpcResult.astro`: Rich card displaying generated NPC.
@@ -1349,7 +1349,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add NPC result card component`
- Files: `src/components/dm/AiNpcResult.astro`
- [ ] **18. Build Generation History Panel Component**
- [x] **18. Build Generation History Panel Component**
**What to do**:
- Create `src/components/dm/GenerationHistory.astro`: Right-panel list of generated NPCs.
@@ -1468,7 +1468,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add quota and tier badge components`
- Files: `src/components/dm/QuotaBadge.astro`, `src/components/dm/TierBadge.astro`, `src/components/dm/AuthPanel.astro`
- [ ] **20. Migrate Notes to DB for PRO + localStorage Fallback**
- [x] **20. Migrate Notes to DB for PRO + localStorage Fallback**
**What to do**:
- Update `src/components/dm/NotesPanel.astro`: Add tier-aware persistence.
@@ -1538,7 +1538,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(pro): add notes DB persistence for PRO users`
- Files: `src/components/dm/NotesPanel.astro`, `src/lib/client/notes.ts`
- [ ] **21. Migrate Initiative to DB for PRO + sessionStorage Fallback**
- [x] **21. Migrate Initiative to DB for PRO + sessionStorage Fallback**
**What to do**:
- Update `src/components/dm/InitiativeTracker.astro`: Add tier-aware persistence.
@@ -1599,7 +1599,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(pro): add initiative DB persistence for PRO users`
- Files: `src/components/dm/InitiativeTracker.astro`, `src/lib/client/initiative.ts`
- [ ] **22. Add FREE → PRO Data Import Flow**
- [x] **22. Add FREE → PRO Data Import Flow**
**What to do**:
- Create import modal/dialog: when FREE user subscribes to PRO (tier changes to 'pro'), show "Import existing data?" prompt.
@@ -1726,7 +1726,7 @@ Max Concurrent: 6 (Wave 6)
- Message: `feat(ui): add translate button to Open5e reference cards`
- Files: `src/components/dm/Open5eReference.astro`
- [ ] **24. Wire Translation Service to UI**
- [x] **24. Wire Translation Service to UI**
**What to do**:
- Create `src/lib/client/translation.ts`: Client-side translation cache and API caller.
+5 -1
View File
@@ -3,7 +3,11 @@ WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev
COPY dist ./dist
COPY drizzle ./drizzle
COPY src/db/migrate.mjs ./src/db/migrate.mjs
EXPOSE 4321
ENV HOST=0.0.0.0
ENV PORT=4321
CMD ["node", "./dist/server/entry.mjs"]
# Run pending migrations on startup, then start the app.
# Fails the container if migrations fail (so we don't serve with a stale schema).
CMD ["sh", "-c", "node ./src/db/migrate.mjs && node ./dist/server/entry.mjs"]
+3 -4
View File
@@ -9,7 +9,6 @@ services:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=4321
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/randify
depends_on:
postgres:
condition: service_healthy
@@ -23,11 +22,11 @@ services:
postgres:
image: postgres:16-alpine
ports:
- "5432:5432"
# No public port mapping: Postgres is reachable only via the internal
# docker network. Use an SSH tunnel for ad-hoc admin access.
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres}
- POSTGRES_DB=randify
volumes:
- postgres_data:/var/lib/postgresql/data
+8
View File
@@ -19,4 +19,12 @@ export default [
"@typescript-eslint/no-unused-vars": "off",
},
},
{
files: ["src/db/migrate.mjs"],
languageOptions: {
globals: {
...globals.node,
},
},
},
];
+1 -1
View File
@@ -17,7 +17,7 @@
"format": "prettier --write .",
"format:check": "prettier --check .",
"db:generate": "drizzle-kit generate",
"db:migrate": "tsx src/db/migrate.ts",
"db:migrate": "node src/db/migrate.mjs",
"db:studio": "drizzle-kit studio"
},
"dependencies": {
+69
View File
@@ -0,0 +1,69 @@
---
import AiNpcForm from "./AiNpcForm.astro";
import AiSpellForm from "./AiSpellForm.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
---
<div data-testid="ai-kind-switcher">
<div class="inline-flex rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] p-1 mb-4" role="tablist" aria-label="Тип генератора">
<button
type="button"
role="tab"
id="ai-kind-npc"
aria-selected="true"
aria-controls="ai-kind-panel-npc"
class="px-4 py-1.5 text-sm font-semibold rounded-md transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]"
data-kind="npc"
data-testid="ai-kind-tab-npc"
>
{T.aiKindNpc}
</button>
<button
type="button"
role="tab"
id="ai-kind-spell"
aria-selected="false"
aria-controls="ai-kind-panel-spell"
class="px-4 py-1.5 text-sm font-semibold rounded-md transition-colors text-[var(--text-secondary)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]"
data-kind="spell"
data-testid="ai-kind-tab-spell"
>
{T.aiKindSpell}
</button>
</div>
<div id="ai-kind-panel-npc" role="tabpanel" aria-labelledby="ai-kind-npc">
<AiNpcForm />
</div>
<div id="ai-kind-panel-spell" role="tabpanel" aria-labelledby="ai-kind-spell" class="hidden">
<AiSpellForm />
</div>
</div>
<script is:inline>
(function () {
const npcTab = document.getElementById("ai-kind-npc");
const spellTab = document.getElementById("ai-kind-spell");
const npcPanel = document.getElementById("ai-kind-panel-npc");
const spellPanel = document.getElementById("ai-kind-panel-spell");
if (!npcTab || !spellTab || !npcPanel || !spellPanel) return;
const activeClasses =
"px-4 py-1.5 text-sm font-semibold rounded-md transition-colors bg-[var(--accent)] text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]";
const inactiveClasses =
"px-4 py-1.5 text-sm font-semibold rounded-md transition-colors text-[var(--text-secondary)] hover:text-[var(--text-primary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--gold)]";
function setKind(kind) {
const isNpc = kind === "npc";
npcTab.className = isNpc ? activeClasses : inactiveClasses;
spellTab.className = isNpc ? inactiveClasses : activeClasses;
npcTab.setAttribute("aria-selected", String(isNpc));
spellTab.setAttribute("aria-selected", String(!isNpc));
npcPanel.classList.toggle("hidden", !isNpc);
spellPanel.classList.toggle("hidden", isNpc);
}
npcTab.addEventListener("click", () => setKind("npc"));
spellTab.addEventListener("click", () => setKind("spell"));
})();
</script>
+218
View File
@@ -0,0 +1,218 @@
---
import DmCard from "./DmCard.astro";
import DmButton from "./DmButton.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
export interface Props {
disabled?: boolean;
}
const { disabled = false } = Astro.props;
const selectClasses =
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] appearance-none cursor-pointer";
const labelClasses = "block text-sm font-medium text-[var(--text-secondary)] mb-1";
---
<DmCard dataTestid="ai-npc-form-card">
<form
id="ai-npc-form"
class="space-y-4"
data-testid="ai-npc-form"
>
<!-- Race -->
<div>
<label for="npc-race" class={labelClasses}>
{T.aiFormRace}
</label>
<select id="npc-race" name="race" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Human">{T.raceHuman}</option>
<option value="Elf">{T.raceElf}</option>
<option value="Dwarf">{T.raceDwarf}</option>
<option value="Halfling">{T.raceHalfling}</option>
<option value="Orc">{T.raceOrc}</option>
<option value="Tiefling">{T.raceTiefling}</option>
<option value="Dragonborn">{T.raceDragonborn}</option>
<option value="Gnome">{T.raceGnome}</option>
<option value="Half-Elf">{T.raceHalfElf}</option>
<option value="Half-Orc">{T.raceHalfOrc}</option>
</select>
</div>
<!-- Role -->
<div>
<label for="npc-role" class={labelClasses}>
{T.aiFormRole}
</label>
<select id="npc-role" name="role" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Warrior">{T.roleWarrior}</option>
<option value="Mage">{T.roleMage}</option>
<option value="Rogue">{T.roleRogue}</option>
<option value="Cleric">{T.roleCleric}</option>
<option value="Ranger">{T.roleRanger}</option>
<option value="Paladin">{T.rolePaladin}</option>
<option value="Bard">{T.roleBard}</option>
<option value="Barbarian">{T.roleBarbarian}</option>
<option value="Druid">{T.roleDruid}</option>
<option value="Warlock">{T.roleWarlock}</option>
</select>
</div>
<!-- Level -->
<div>
<label for="npc-level" class={labelClasses}>
{T.aiFormLevel}
</label>
<input
id="npc-level"
name="level"
type="number"
min="1"
max="20"
value="1"
class="w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)]"
required
disabled={disabled}
/>
</div>
<!-- Tone -->
<div>
<label for="npc-tone" class={labelClasses}>
{T.aiFormTone}
</label>
<select id="npc-tone" name="tone" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Serious">{T.toneSerious}</option>
<option value="Humorous">{T.toneHumorous}</option>
<option value="Dark">{T.toneDark}</option>
<option value="Heroic">{T.toneHeroic}</option>
<option value="Mysterious">{T.toneMysterious}</option>
</select>
</div>
<!-- Open5e Reference Toggle -->
<div class="flex items-center gap-3">
<input
id="npc-use-reference"
name="useOpen5eReference"
type="checkbox"
class="w-5 h-5 rounded border-[var(--border-color)] bg-[var(--bg-input)] text-[var(--accent)] accent-[var(--accent)] focus:ring-[var(--gold)] focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--bg-primary)] cursor-pointer"
disabled={disabled}
/>
<label for="npc-use-reference" class="text-sm text-[var(--text-secondary)] cursor-pointer">
{T.aiFormUseReference}
</label>
</div>
<!-- Submit -->
<div class="pt-2">
<DmButton
id="ai-npc-submit"
type="submit"
variant="primary"
size="md"
class="w-full"
disabled={disabled}
dataTestid="ai-npc-submit"
>
{T.aiFormSubmit}
</DmButton>
</div>
<!-- Status -->
<div
id="ai-npc-status"
class="text-sm text-center min-h-[1.5rem]"
aria-live="polite"
data-testid="ai-npc-status"
></div>
</form>
</DmCard>
<script is:inline define:vars={{ generatingLabel: T.npcGenerating, submitLabel: T.npcGenerate, rateLimitTemplate: T.npcErrorRateLimit, genericError: T.npcErrorGeneric }}>
(function () {
const form = document.getElementById("ai-npc-form") as HTMLFormElement | null;
const submitBtn = document.getElementById("ai-npc-submit") as HTMLButtonElement | null;
const statusEl = document.getElementById("ai-npc-status");
if (!form || !submitBtn || !statusEl) return;
let isSubmitting = false;
function setStatus(message: string, type: "error" | "info" = "info") {
statusEl.textContent = message;
statusEl.className =
"text-sm text-center min-h-[1.5rem] " +
(type === "error" ? "text-[var(--danger)]" : "text-[var(--text-secondary)]");
}
function setLoading(loading: boolean) {
isSubmitting = loading;
submitBtn.disabled = loading;
submitBtn.textContent = loading ? generatingLabel : submitLabel;
submitBtn.setAttribute("aria-busy", loading ? "true" : "false");
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (isSubmitting) return;
const formData = new FormData(form);
const body = {
race: String(formData.get("race")),
role: String(formData.get("role")),
level: parseInt(String(formData.get("level")), 10),
tone: String(formData.get("tone")),
useOpen5eReference: formData.get("useOpen5eReference") === "on",
};
setLoading(true);
setStatus("");
try {
const response = await fetch("/api/dm/ai/generate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (response.status === 429) {
const retryAfter = response.headers.get("Retry-After");
const minutes = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 60) : 60;
setStatus(rateLimitTemplate.replace("{minutes}", String(minutes)), "error");
setLoading(false);
return;
}
if (!response.ok) {
setStatus(genericError, "error");
setLoading(false);
return;
}
const result = await response.json();
if (!result.npc) {
setStatus(genericError, "error");
setLoading(false);
return;
}
setStatus("");
window.dispatchEvent(
new CustomEvent("npc-generated", {
detail: result.npc,
bubbles: true,
})
);
} catch {
setStatus(genericError, "error");
} finally {
setLoading(false);
}
});
})();
</script>
+172
View File
@@ -0,0 +1,172 @@
---
import DmCard from "./DmCard.astro";
import DmButton from "./DmButton.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import type { NPCResult } from "@/lib/ai/types";
export interface Props {
npc: NPCResult;
onRegenerate?: () => void;
}
const { npc, onRegenerate } = Astro.props;
---
<DmCard padding="lg" dataTestid="ai-npc-result">
<div class="flex items-start justify-between gap-4 mb-5">
<div class="min-w-0">
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">
{npc.name}
</h3>
<p class="text-sm text-[var(--text-secondary)] mt-1">
{npc.race} · {npc.role} · {T.npcLevel} {npc.level} · {T.npcCr} {npc.cr}
</p>
</div>
<div class="flex items-center gap-2 shrink-0">
<DmButton
variant="secondary"
size="sm"
id="npc-copy-btn"
dataTestid="npc-copy-btn"
>
{T.npcCopyJson}
</DmButton>
{
onRegenerate && (
<DmButton
variant="primary"
size="sm"
id="npc-regen-btn"
dataTestid="npc-regen-btn"
>
{T.npcRegenerate}
</DmButton>
)
}
</div>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-hp"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcHp}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.hp}</div>
</div>
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-ac"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcAc}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.ac}</div>
</div>
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-speed"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcSpeed}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.speed}</div>
</div>
<div
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
data-testid="npc-stat-cr"
>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
{T.npcCr}
</div>
<div class="text-lg font-bold text-[var(--accent)]">{npc.cr}</div>
</div>
</div>
<div class="space-y-3 mb-5">
<div data-testid="npc-appearance">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcAppearance}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.appearance}
</p>
</div>
<div data-testid="npc-trait">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcTrait}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.trait}
</p>
</div>
<div data-testid="npc-motivation">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcMotivation}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.motivation}
</p>
</div>
<div data-testid="npc-secret">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
{T.npcSecret}
</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
{npc.secret}
</p>
</div>
</div>
<div
class="border-l-4 border-[var(--accent)] pl-4 py-2 italic text-[var(--text-cream)] bg-[var(--bg-secondary)]/50 rounded-r-lg mb-5"
data-testid="npc-history"
>
<p class="text-sm leading-relaxed">{npc.history}</p>
</div>
<div class="flex flex-wrap gap-2">
<span
class="px-3 py-1 text-xs font-medium rounded-full bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20"
>
{npc.race}
</span>
<span
class="px-3 py-1 text-xs font-medium rounded-full bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20"
>
{npc.role}
</span>
</div>
</DmCard>
<script is:inline define:vars={{ npcJson: JSON.stringify(npc), copiedLabel: T.npcCopied, hasRegenerate: !!onRegenerate }}>
(function () {
const copyBtn = document.getElementById("npc-copy-btn");
const regenBtn = document.getElementById("npc-regen-btn");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(npcJson);
const originalText = copyBtn.textContent || "";
copyBtn.textContent = copiedLabel;
setTimeout(() => {
copyBtn.textContent = originalText;
}, 1500);
} catch {
/* ignore clipboard errors */
}
});
}
if (regenBtn && hasRegenerate) {
regenBtn.addEventListener("click", () => {
regenBtn.dispatchEvent(
new CustomEvent("npc-regenerate", { bubbles: true })
);
});
}
})();
</script>
+33
View File
@@ -0,0 +1,33 @@
---
import DmCard from "./DmCard.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
export interface Props {
resetAt: Date | string | null;
}
const { resetAt } = Astro.props;
function minutesUntil(target: Date | string | null): number {
if (!target) return 60;
const t = typeof target === "string" ? new Date(target) : target;
const diffMs = t.getTime() - Date.now();
return Math.max(1, Math.ceil(diffMs / 60000));
}
const minutes = minutesUntil(resetAt);
const resetMsg = T.aiQuotaResetsIn.replace("{minutes}", String(minutes));
---
<DmCard padding="lg" dataTestid="ai-quota-exhausted">
<div class="text-center space-y-3">
<div class="text-4xl" aria-hidden="true">⏳</div>
<h3 class="text-lg font-bold text-[var(--text-primary)]">
{T.aiQuotaExhausted}
</h3>
<p class="text-sm text-[var(--text-secondary)]">{resetMsg}</p>
<p class="text-sm text-[var(--text-cream)] pt-2 border-t border-[var(--border-color)]">
{T.aiQuotaUpgradeHint}
</p>
</div>
</DmCard>
+305
View File
@@ -0,0 +1,305 @@
---
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 inputClasses =
"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)]";
const labelClasses = "block text-sm font-medium text-[var(--text-secondary)] mb-1";
---
<DmCard dataTestid="ai-spell-form-card">
<form id="ai-spell-form" class="space-y-4" data-testid="ai-spell-form">
<div>
<label for="spell-name" class={labelClasses}>{T.spellFormName}</label>
<input
id="spell-name"
name="name"
type="text"
placeholder=""
class={inputClasses}
disabled={disabled}
/>
</div>
<div>
<label for="spell-level" class={labelClasses}>{T.spellFormLevel}</label>
<select id="spell-level" name="level" class={selectClasses} required disabled={disabled}>
<option value="0">{T.spellCantripLabel}</option>
<option value="1" selected>1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
<option value="7">7</option>
<option value="8">8</option>
<option value="9">9</option>
</select>
</div>
<div>
<label for="spell-school" class={labelClasses}>{T.spellFormSchool}</label>
<select id="spell-school" name="school" class={selectClasses} required disabled={disabled}>
<option value="" disabled selected>{T.aiFormSelectOption}</option>
<option value="Evocation">{T.schoolEvocation}</option>
<option value="Abjuration">{T.schoolAbjuration}</option>
<option value="Conjuration">{T.schoolConjuration}</option>
<option value="Divination">{T.schoolDivination}</option>
<option value="Enchantment">{T.schoolEnchantment}</option>
<option value="Illusion">{T.schoolIllusion}</option>
<option value="Necromancy">{T.schoolNecromancy}</option>
<option value="Transmutation">{T.schoolTransmutation}</option>
</select>
</div>
<div>
<label for="spell-classes" class={labelClasses}>{T.spellFormClasses}</label>
<input
id="spell-classes"
name="classes"
type="text"
placeholder="Wizard, Sorcerer"
class={inputClasses}
disabled={disabled}
/>
</div>
<div>
<label for="spell-tone" class={labelClasses}>{T.spellFormTone}</label>
<select id="spell-tone" name="tone" class={selectClasses} disabled={disabled}>
<option value="">{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>
<div class="flex items-center gap-3">
<input
id="spell-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="spell-use-reference" class="text-sm text-[var(--text-secondary)] cursor-pointer">
{T.aiFormUseReference}
</label>
</div>
<div class="pt-2">
<DmButton
id="ai-spell-submit"
type="submit"
variant="primary"
size="md"
class="w-full"
disabled={disabled}
dataTestid="ai-spell-submit"
>
{T.spellFormSubmit}
</DmButton>
</div>
<div
id="ai-spell-status"
class="text-sm text-center min-h-[1.5rem]"
aria-live="polite"
data-testid="ai-spell-status"
></div>
</form>
</DmCard>
<div id="ai-spell-result" class="mt-4 hidden" data-testid="ai-spell-result"></div>
<script is:inline define:vars={{
generatingLabel: T.spellGenerating,
submitLabel: T.spellGenerate,
rateLimitTemplate: T.spellErrorRateLimit,
genericError: T.spellErrorGeneric,
labels: {
level: T.spellLabelLevel,
school: T.spellLabelSchool,
castingTime: T.spellLabelCastingTime,
range: T.spellLabelRange,
components: T.spellLabelComponents,
duration: T.spellLabelDuration,
classes: T.spellLabelClasses,
description: T.spellLabelDescription,
higherLevels: T.spellLabelHigherLevels,
cantrip: T.spellCantripLabel,
copy: T.spellCopyJson,
copied: T.spellCopied,
},
}}>
(function () {
const form = document.getElementById("ai-spell-form");
const submitBtn = document.getElementById("ai-spell-submit");
const statusEl = document.getElementById("ai-spell-status");
const resultEl = document.getElementById("ai-spell-result");
if (!form || !submitBtn || !statusEl || !resultEl) return;
let isSubmitting = false;
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = String(s ?? "");
return div.innerHTML;
}
function formatLevel(level) {
return level === 0 ? labels.cantrip : `${level} ${labels.level}`;
}
function renderSpell(spell) {
const json = JSON.stringify(spell, null, 2);
const higherLevelsBlock = spell.higher_levels
? `<div class="mt-3">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.higherLevels)}</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed">${escapeHtml(spell.higher_levels)}</p>
</div>`
: "";
resultEl.innerHTML = `
<div class="rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5" data-testid="ai-spell-card">
<div class="flex items-start justify-between gap-4 mb-4">
<div class="min-w-0">
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">${escapeHtml(spell.name)}</h3>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(formatLevel(spell.level))} · ${escapeHtml(spell.school)}</p>
</div>
<button
type="button"
id="spell-copy-btn"
class="inline-flex items-center justify-center px-4 py-1.5 text-sm font-semibold rounded-lg bg-[var(--bg-card)] text-[var(--text-primary)] border border-[var(--border-color)] hover:border-[var(--gold)] hover:text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] transition-all"
data-testid="spell-copy-btn"
>${escapeHtml(labels.copy)}</button>
</div>
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.casting_time)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.castingTime)}</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.range)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.range)}</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.components)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.components)}</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(spell.duration)}</p>
<p class="text-xs text-[var(--text-secondary)]">${escapeHtml(labels.duration)}</p>
</div>
</div>
<div class="mb-3">
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.classes)}</div>
<p class="text-sm text-[var(--text-primary)]">${escapeHtml(spell.classes)}</p>
</div>
<div>
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">${escapeHtml(labels.description)}</div>
<p class="text-sm text-[var(--text-primary)] leading-relaxed whitespace-pre-line">${escapeHtml(spell.description)}</p>
</div>
${higherLevelsBlock}
</div>
`;
resultEl.classList.remove("hidden");
const copyBtn = document.getElementById("spell-copy-btn");
if (copyBtn) {
copyBtn.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(json);
const originalText = copyBtn.textContent || "";
copyBtn.textContent = labels.copied;
setTimeout(() => { copyBtn.textContent = originalText; }, 1500);
} catch { /* ignore */ }
});
}
}
function setStatus(message, type) {
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) {
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 nameValue = String(formData.get("name") ?? "").trim();
const classesValue = String(formData.get("classes") ?? "").trim();
const toneValue = String(formData.get("tone") ?? "").trim();
const body = {
level: parseInt(String(formData.get("level")), 10),
school: String(formData.get("school")),
useOpen5eReference: formData.get("useOpen5eReference") === "on",
};
if (nameValue) body.name = nameValue;
if (classesValue) body.classes = classesValue;
if (toneValue) body.tone = toneValue;
setLoading(true);
setStatus("");
try {
const response = await fetch("/api/dm/ai/generate-spell", {
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.spell) {
setStatus(genericError, "error");
setLoading(false);
return;
}
setStatus("");
renderSpell(result.spell);
window.dispatchEvent(new CustomEvent("spell-generated", { detail: result.spell, bubbles: true }));
} catch {
setStatus(genericError, "error");
} finally {
setLoading(false);
}
});
})();
</script>
+335
View File
@@ -0,0 +1,335 @@
---
export interface Props {
tier: 'free' | 'pro';
userId?: number;
}
import DmCard from './DmCard.astro';
import { dmTranslations as T } from '@/i18n/dm-translations';
const { tier, userId } = Astro.props;
---
<div
class="generation-history"
data-tier={tier}
data-user-id={userId}
data-testid="generation-history"
>
<DmCard padding="md">
<div class="flex items-center justify-between mb-3">
<h3 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
{T.historyTitle}
</h3>
<span
id="history-count"
class="text-xs text-[var(--text-muted)]"
data-testid="history-count"
>
0
</span>
</div>
<div id="history-loading" class="hidden py-4 text-center text-[var(--text-muted)] text-sm">
{T.loading}
</div>
<div
id="history-empty-pro"
class="hidden flex flex-col items-center justify-center py-6 text-[var(--text-muted)]"
data-testid="history-empty-pro"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-2 opacity-40"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" />
<line x1="16" y1="17" x2="8" y2="17" />
<polyline points="10 9 9 9 8 9" />
</svg>
<span class="text-sm">{T.historyEmptyPro}</span>
</div>
<div
id="history-empty-free"
class="hidden flex flex-col items-center justify-center py-6 px-4 rounded-lg border-2 border-dashed border-[var(--border-color)] text-center"
data-testid="history-empty-free"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="28"
height="28"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.5"
stroke-linecap="round"
stroke-linejoin="round"
class="mb-2 text-[var(--accent)] opacity-60"
>
<path d="M12 2L2 7l10 5 10-5-10-5z" />
<path d="M2 17l10 5 10-5" />
<path d="M2 12l10 5 10-5" />
</svg>
<p class="text-sm text-[var(--text-secondary)] mb-2">{T.historyEmptyFree}</p>
<a
href="/dm/upgrade"
class="inline-flex items-center justify-center rounded-lg px-4 py-1.5 text-sm font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors"
>
{T.historyUpgradeCta}
</a>
</div>
<ul
id="history-list"
class="space-y-1 max-h-60 overflow-y-auto hidden"
role="list"
aria-label={T.historyTitle}
data-testid="history-list"
>
</ul>
</DmCard>
</div>
<script>
import type { NPCResult } from '@/lib/ai/types';
interface HistoryItem {
id: number;
name: string;
race: string | null;
role: string | null;
level: number | null;
content: Record<string, unknown>;
createdAt: string;
}
const FREE_STORAGE_KEY = 'dm-ai-history-free';
const MAX_HISTORY = 20;
function loadFreeHistory(): HistoryItem[] {
try {
const raw = sessionStorage.getItem(FREE_STORAGE_KEY);
if (!raw) return [];
return JSON.parse(raw) as HistoryItem[];
} catch {
return [];
}
}
function saveFreeHistory(items: HistoryItem[]) {
try {
sessionStorage.setItem(FREE_STORAGE_KEY, JSON.stringify(items.slice(0, MAX_HISTORY)));
} catch {
// sessionStorage may be unavailable
}
}
function formatTime(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
}
function renderHistory(container: HTMLElement, items: HistoryItem[], activeId: number | null) {
const list = container.querySelector<HTMLUListElement>('#history-list');
const emptyPro = container.querySelector<HTMLDivElement>('#history-empty-pro');
const emptyFree = container.querySelector<HTMLDivElement>('#history-empty-free');
const countEl = container.querySelector<HTMLSpanElement>('#history-count');
if (!list || !emptyPro || !emptyFree || !countEl) return;
countEl.textContent = String(items.length);
if (items.length === 0) {
list.classList.add('hidden');
list.innerHTML = '';
const tier = container.dataset.tier;
if (tier === 'pro') {
emptyPro.classList.remove('hidden');
emptyFree.classList.add('hidden');
} else {
emptyPro.classList.add('hidden');
emptyFree.classList.remove('hidden');
}
return;
}
emptyPro.classList.add('hidden');
emptyFree.classList.add('hidden');
list.classList.remove('hidden');
list.innerHTML = items
.map((item) => {
const isActive = item.id === activeId;
const raceRole = [item.race, item.role].filter(Boolean).join(' · ');
const levelText = item.level ? `Ур. ${item.level}` : '';
const meta = [raceRole, levelText].filter(Boolean).join(' · ');
const dateText = formatDate(item.createdAt);
const timeText = formatTime(item.createdAt);
return `
<li
data-history-id="${item.id}"
class="group cursor-pointer rounded-lg px-3 py-2.5 text-sm transition-all duration-[var(--transition-base)] border ${isActive
? 'bg-[var(--accent)]/10 border-[var(--accent)]/40'
: 'bg-[var(--bg-secondary)] border-transparent hover:bg-[var(--bg-secondary)] hover:border-[var(--border-color)]'
}"
role="button"
tabindex="0"
aria-pressed="${isActive}"
>
<div class="flex items-center justify-between">
<span class="font-medium ${isActive ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'} truncate pr-2">
${escapeHtml(item.name)}
</span>
<span class="text-xs text-[var(--text-muted)] shrink-0">
${escapeHtml(dateText)} ${escapeHtml(timeText)}
</span>
</div>
${meta ? `<div class="text-xs text-[var(--text-secondary)] mt-0.5 truncate">${escapeHtml(meta)}</div>` : ''}
</li>
`;
})
.join('');
list.querySelectorAll<HTMLLIElement>('li[data-history-id]').forEach((li) => {
li.addEventListener('click', () => {
const id = Number(li.dataset.historyId);
selectItem(container, items, id);
});
li.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const id = Number(li.dataset.historyId);
selectItem(container, items, id);
}
});
});
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function selectItem(container: HTMLElement, items: HistoryItem[], id: number) {
const item = items.find((i) => i.id === id);
if (!item) return;
renderHistory(container, items, id);
const npcData: NPCResult & { id: number } = {
...(item.content as unknown as NPCResult),
id: item.id,
};
container.dispatchEvent(
new CustomEvent('npc-selected', {
detail: npcData,
bubbles: true,
})
);
}
async function fetchProHistory(): 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[] = [];
const 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();
} 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>
+296
View File
@@ -0,0 +1,296 @@
---
export interface Props {
tier?: "free" | "pro";
userId?: number;
}
import DmButton from "./DmButton.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
const { tier = "free", userId } = Astro.props;
---
<div
id="dm-import-modal"
class="hidden fixed inset-0 z-[100] items-center justify-center bg-black/70 backdrop-blur-sm p-4"
data-testid="dm-import-modal"
data-tier={tier}
data-user-id={userId ?? ""}
role="dialog"
aria-modal="true"
aria-labelledby="dm-import-title"
>
<div
class="w-full max-w-md rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-2xl shadow-black/40 p-6"
>
<h2
id="dm-import-title"
class="text-xl font-bold text-[var(--text-primary)] mb-2"
>
{T.importTitle}
</h2>
<p class="text-sm text-[var(--text-secondary)] mb-4" id="dm-import-prompt">
{T.importPrompt}
</p>
<ul
id="dm-import-summary"
class="text-sm text-[var(--text-cream)] mb-4 space-y-1"
></ul>
<div
id="dm-import-progress"
class="text-sm text-[var(--text-secondary)] min-h-[1.25rem] mb-4"
aria-live="polite"
data-testid="dm-import-progress"
></div>
<div
id="dm-import-conflict"
class="hidden rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)] p-3 mb-4"
>
<p class="text-sm text-[var(--text-cream)] mb-3" id="dm-import-conflict-text">
{T.importNotesConflict}
</p>
<div class="flex flex-wrap gap-2">
<DmButton
id="dm-import-conflict-keep"
variant="secondary"
size="sm"
dataTestid="dm-import-conflict-keep"
>
{T.importConflictKeepBoth}
</DmButton>
<DmButton
id="dm-import-conflict-replace"
variant="secondary"
size="sm"
dataTestid="dm-import-conflict-replace"
>
{T.importConflictReplace}
</DmButton>
<DmButton
id="dm-import-conflict-skip"
variant="ghost"
size="sm"
dataTestid="dm-import-conflict-skip"
>
{T.importConflictSkip}
</DmButton>
</div>
</div>
<div class="flex items-center justify-end gap-2">
<DmButton
id="dm-import-skip-btn"
variant="ghost"
size="sm"
dataTestid="dm-import-skip-btn"
>
{T.importSkip}
</DmButton>
<DmButton
id="dm-import-confirm-btn"
variant="primary"
size="sm"
dataTestid="dm-import-confirm-btn"
>
{T.importBtn}
</DmButton>
</div>
</div>
</div>
<script>
import {
getLocalInitiative,
getLocalNotes,
hasLocalData,
markAsked,
shouldShowImport,
type LocalCombatant,
} from "@/lib/client/import";
import { dmTranslations as T } from "@/i18n/dm-translations";
type ConflictChoice = "keep" | "replace" | "skip" | null;
const modal = document.getElementById("dm-import-modal");
if (modal) {
const tier = modal.dataset.tier ?? "free";
const userIdRaw = modal.dataset.userId ?? "";
const userId = userIdRaw ? parseInt(userIdRaw, 10) : undefined;
const summaryEl = modal.querySelector("#dm-import-summary") as HTMLUListElement;
const progressEl = modal.querySelector("#dm-import-progress") as HTMLDivElement;
const conflictBlock = modal.querySelector("#dm-import-conflict") as HTMLDivElement;
const confirmBtn = modal.querySelector("#dm-import-confirm-btn") as HTMLButtonElement;
const skipBtn = modal.querySelector("#dm-import-skip-btn") as HTMLButtonElement;
const keepBtn = modal.querySelector("#dm-import-conflict-keep") as HTMLButtonElement;
const replaceBtn = modal.querySelector("#dm-import-conflict-replace") as HTMLButtonElement;
const skipConflictBtn = modal.querySelector("#dm-import-conflict-skip") as HTMLButtonElement;
const promptEl = modal.querySelector("#dm-import-prompt") as HTMLParagraphElement;
function show() {
modal!.classList.remove("hidden");
modal!.classList.add("flex");
}
function hide() {
modal!.classList.add("hidden");
modal!.classList.remove("flex");
}
function renderSummary() {
const notes = getLocalNotes();
const initiative = getLocalInitiative();
summaryEl.innerHTML = "";
if (notes.trim().length > 0) {
const li = document.createElement("li");
li.textContent = `• ${T.importItemNotes}: ${notes.length} ${T.importChars}`;
summaryEl.appendChild(li);
}
if (initiative.length > 0) {
const li = document.createElement("li");
li.textContent = `• ${T.importItemInitiative}: ${initiative.length}`;
summaryEl.appendChild(li);
}
}
async function getExistingDmNote(): Promise<{ id: number; content: string | null } | null> {
try {
const res = await fetch("/api/dm/notes");
if (!res.ok) return null;
const list = (await res.json()) as Array<{ id: number; title: string; content: string | null }>;
return list.find((n) => n.title === "DM Notes") ?? null;
} catch {
return null;
}
}
async function postNote(title: string, content: string): Promise<boolean> {
try {
const res = await fetch("/api/dm/notes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title, content }),
});
return res.ok;
} catch {
return false;
}
}
async function putNote(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 postInitiative(name: string, participants: LocalCombatant[]): Promise<boolean> {
try {
const res = await fetch("/api/dm/initiative", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, participants }),
});
return res.ok;
} catch {
return false;
}
}
function setProgress(text: string, isError = false) {
progressEl.textContent = text;
progressEl.classList.toggle("text-[var(--danger)]", isError);
}
function askConflict(): Promise<ConflictChoice> {
return new Promise((resolve) => {
conflictBlock.classList.remove("hidden");
const cleanup = (choice: ConflictChoice) => {
conflictBlock.classList.add("hidden");
keepBtn.removeEventListener("click", onKeep);
replaceBtn.removeEventListener("click", onReplace);
skipConflictBtn.removeEventListener("click", onSkip);
resolve(choice);
};
const onKeep = () => cleanup("keep");
const onReplace = () => cleanup("replace");
const onSkip = () => cleanup("skip");
keepBtn.addEventListener("click", onKeep);
replaceBtn.addEventListener("click", onReplace);
skipConflictBtn.addEventListener("click", onSkip);
});
}
async function runImport() {
confirmBtn.disabled = true;
skipBtn.disabled = true;
promptEl.classList.add("opacity-60");
const notes = getLocalNotes();
const initiative = getLocalInitiative();
let notesOk = true;
let initOk = true;
// Notes
if (notes.trim().length > 0) {
setProgress(T.importNotesProgress);
const existing = await getExistingDmNote();
if (existing && existing.content && existing.content.trim().length > 0) {
const choice = await askConflict();
if (choice === "skip" || choice === null) {
notesOk = true;
} else if (choice === "replace") {
notesOk = await putNote(existing.id, notes);
} else {
notesOk = await postNote(`DM Notes (импорт)`, notes);
}
} else if (existing) {
notesOk = await putNote(existing.id, notes);
} else {
notesOk = await postNote("DM Notes", notes);
}
}
// Initiative
if (initiative.length > 0) {
setProgress(T.importInitiativeProgress);
const sessionName = `${T.importInitiativeName} ${new Date().toLocaleDateString("ru-RU")}`;
initOk = await postInitiative(sessionName, initiative);
}
if (notesOk && initOk) {
setProgress(T.importDone);
markAsked();
setTimeout(() => hide(), 1200);
} else {
setProgress(T.importError, true);
confirmBtn.disabled = false;
skipBtn.disabled = false;
promptEl.classList.remove("opacity-60");
}
}
confirmBtn.addEventListener("click", () => {
void runImport();
});
skipBtn.addEventListener("click", () => {
markAsked();
hide();
});
if (shouldShowImport(tier, userId) && hasLocalData()) {
renderSummary();
show();
}
}
</script>
+219 -1
View File
@@ -1,11 +1,59 @@
---
export interface Props {
tier?: 'free' | 'pro';
userId?: number;
}
import DmButton from "./DmButton.astro";
import DmInput from "./DmInput.astro";
import DmCard from "./DmCard.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
const { tier = 'free', userId } = Astro.props;
---
<div id="initiative-tracker" class="w-full max-w-xl mx-auto" data-testid="init-section">
<div
id="initiative-tracker"
class="w-full max-w-xl mx-auto"
data-testid="init-section"
data-tier={tier}
data-user-id={userId}
>
<!-- PRO session management -->
{tier === 'pro' && (
<div class="mb-6">
<DmCard>
<div class="flex flex-col sm:flex-row items-end gap-2 mb-2">
<div class="flex-1 w-full">
<DmInput
id="it-session-name"
type="text"
placeholder={T.sessionNamePlaceholder}
label=""
dataTestid="it-session-name"
/>
</div>
<DmButton id="it-save-session-btn" variant="primary" size="sm" dataTestid="it-save-session-btn">
{T.saveSession}
</DmButton>
<DmButton id="it-new-session-btn" variant="secondary" size="sm" dataTestid="it-new-session-btn">
{T.newSession}
</DmButton>
</div>
<div
id="it-session-status"
class="text-sm min-h-[1.25rem] text-[var(--text-secondary)]"
data-testid="it-session-status"
></div>
<div
id="it-session-list"
class="mt-2 max-h-40 overflow-y-auto space-y-1"
data-testid="it-session-list"
></div>
</DmCard>
</div>
)}
<!-- Add combatant form -->
<div class="mb-6">
<DmCard>
@@ -106,6 +154,7 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
const STORAGE_KEY = "dm-initiative";
// DOM refs
const trackerEl = document.getElementById("initiative-tracker");
const nameInput = document.getElementById("it-name") as HTMLInputElement;
const modifierInput = document.getElementById(
"it-modifier",
@@ -122,7 +171,19 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
const emptyState = document.getElementById("it-empty");
const announcer = document.getElementById("it-announcer");
// PRO refs
const sessionNameInput = document.getElementById("it-session-name") as HTMLInputElement | null;
const saveSessionBtn = document.getElementById("it-save-session-btn");
const newSessionBtn = document.getElementById("it-new-session-btn");
const sessionStatusEl = document.getElementById("it-session-status");
const sessionListEl = document.getElementById("it-session-list");
const tier = (trackerEl?.dataset.tier as 'free' | 'pro') || 'free';
const userId = trackerEl?.dataset.userId ? parseInt(trackerEl.dataset.userId, 10) : undefined;
const isPro = tier === 'pro' && !!userId;
let activeIndex = 0;
let currentSessionId: number | null = null;
interface CombatantData {
id: string;
@@ -392,6 +453,159 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
initiativeInput.value = String(result);
}
// --- PRO Session Management ---
interface DbSession {
id: number;
name: string;
participants: CombatantData[];
createdAt: string;
updatedAt: string;
}
async function apiFetchSessions(): Promise<DbSession[]> {
const res = await fetch('/api/dm/initiative');
if (!res.ok) return [];
return res.json() as Promise<DbSession[]>;
}
async function apiSaveSession(name: string, participants: CombatantData[], id?: number): Promise<DbSession | null> {
if (id) {
const res = await fetch(`/api/dm/initiative?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, participants }),
});
if (!res.ok) return null;
return res.json() as Promise<DbSession>;
} else {
const res = await fetch('/api/dm/initiative', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, participants }),
});
if (!res.ok) return null;
return res.json() as Promise<DbSession>;
}
}
async function apiDeleteSession(id: number): Promise<boolean> {
const res = await fetch(`/api/dm/initiative?id=${id}`, { method: 'DELETE' });
return res.ok;
}
function showSessionStatus(message: string, isError = false) {
if (!sessionStatusEl) return;
sessionStatusEl.textContent = message;
sessionStatusEl.className = `text-sm min-h-[1.25rem] ${isError ? 'text-[var(--danger)]' : 'text-[var(--text-secondary)]'}`;
setTimeout(() => {
if (sessionStatusEl) {
sessionStatusEl.textContent = '';
sessionStatusEl.className = 'text-sm min-h-[1.25rem] text-[var(--text-secondary)]';
}
}, 3000);
}
async function renderSessionList() {
if (!sessionListEl) return;
const sessions = await apiFetchSessions();
if (sessions.length === 0) {
sessionListEl.innerHTML = `<p class="text-xs text-[var(--text-muted)] py-2">Нет сохранённых сессий</p>`;
return;
}
sessionListEl.innerHTML = sessions.map((s) => {
const date = new Date(s.updatedAt).toLocaleDateString('ru-RU', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
const isActive = s.id === currentSessionId;
return `
<div class="flex items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-sm ${isActive ? 'bg-[var(--accent)]/10 border border-[var(--accent)]/30' : 'bg-[var(--bg-secondary)] border border-transparent hover:border-[var(--border-color)]'} transition-colors">
<button type="button" class="it-load-session flex-1 text-left truncate ${isActive ? 'text-[var(--accent)] font-medium' : 'text-[var(--text-primary)]'}" data-id="${s.id}">
${escapeHtml(s.name)} <span class="text-[var(--text-muted)] text-xs">${date}</span>
</button>
<button type="button" class="it-delete-session w-6 h-6 inline-flex items-center justify-center rounded text-[var(--text-secondary)] hover:text-red-400 transition-colors" data-id="${s.id}" aria-label="Удалить ${escapeHtml(s.name)}">
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 6 6 18"/><path d="m6 6 12 12"/>
</svg>
</button>
</div>
`;
}).join('');
sessionListEl.querySelectorAll('.it-load-session').forEach((btn) => {
btn.addEventListener('click', () => {
const id = (btn as HTMLElement).dataset.id;
if (id) loadDbSession(parseInt(id, 10));
});
});
sessionListEl.querySelectorAll('.it-delete-session').forEach((btn) => {
btn.addEventListener('click', () => {
const id = (btn as HTMLElement).dataset.id;
if (id) deleteDbSession(parseInt(id, 10));
});
});
}
async function loadDbSession(id: number) {
try {
const sessions = await apiFetchSessions();
const session = sessions.find((s) => s.id === id);
if (!session) {
showSessionStatus('Ошибка загрузки сессии', true);
return;
}
saveCombatants(session.participants);
activeIndex = 0;
currentSessionId = session.id;
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;
if (sessionNameInput) sessionNameInput.value = result.name;
showSessionStatus('Сессия сохранена');
renderSessionList();
} else {
showSessionStatus('Ошибка сохранения', true);
}
}
async function handleNewSession() {
currentSessionId = null;
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;
if (sessionNameInput) sessionNameInput.value = '';
}
renderSessionList();
} else {
showSessionStatus('Ошибка удаления', true);
}
}
// Event listeners
rollBtn?.addEventListener("click", handleRoll);
addBtn?.addEventListener("click", addCombatant);
@@ -412,7 +626,11 @@ import { dmTranslations as T } from "@/i18n/dm-translations";
}
});
saveSessionBtn?.addEventListener('click', handleSaveSession);
newSessionBtn?.addEventListener('click', handleNewSession);
// Init
updateVisibility();
renderList();
if (isPro) renderSessionList();
</script>
+124 -14
View File
@@ -1,10 +1,17 @@
---
export interface Props {
tier?: 'free' | 'pro';
userId?: number;
}
import DmCard from './DmCard.astro';
import DmButton from './DmButton.astro';
import { dmTranslations as T } from '../../i18n/dm-translations';
const { tier = 'free', userId } = Astro.props;
---
<DmCard padding="md" dataTestid="notes-section">
<DmCard padding="md" dataTestid="notes-section" data-tier={tier} data-user-id={userId}>
<textarea
id="notes-textarea"
class="w-full h-64 xl:h-80 2xl:h-96 bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg p-4 text-[var(--text-primary)] placeholder:text-[var(--text-muted)] focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] transition-all resize-none"
@@ -35,15 +42,20 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
const clearBtn = container.querySelector('[data-testid="notes-clear-btn"]') as HTMLButtonElement;
const copyBtn = container.querySelector('#notes-copy-btn') as HTMLButtonElement;
const tier = (container.dataset.tier as 'free' | 'pro') || 'free';
const userId = container.dataset.userId ? parseInt(container.dataset.userId, 10) : undefined;
const isPro = tier === 'pro' && !!userId;
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let hideIndicatorTimer: ReturnType<typeof setTimeout> | null = null;
let dbNoteId: number | null = null;
function formatTime(date: Date): string {
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
}
function showSavedIndicator(message?: string) {
const text = message || `Сохранено · ${formatTime(new Date())}`;
const text = message || `Сохранено \u00b7 ${formatTime(new Date())}`;
savedIndicator.textContent = text;
savedIndicator.classList.remove('opacity-0');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
@@ -52,29 +64,124 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
}, 1500);
}
function showErrorIndicator(message: string) {
savedIndicator.textContent = message;
savedIndicator.classList.remove('opacity-0');
savedIndicator.classList.add('text-[var(--danger)]');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
hideIndicatorTimer = setTimeout(() => {
savedIndicator.classList.add('opacity-0');
savedIndicator.classList.remove('text-[var(--danger)]');
}, 3000);
}
function updateCharCount() {
const count = textarea.value.length;
charCount.textContent = String(count);
}
function loadSavedNotes() {
const saved = loadNotes();
textarea.value = saved;
async function loadDbNotes(): Promise<{ id: number; content: string | null } | null> {
try {
const res = await fetch('/api/dm/notes');
if (!res.ok) return null;
const notes = await res.json() as Array<{ id: number; title: string; content: string | null }>;
const note = notes.find(n => n.title === 'DM Notes') || notes[0];
return note ?? null;
} catch {
return null;
}
}
async function createDbNote(content: string): Promise<number | null> {
try {
const res = await fetch('/api/dm/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title: 'DM Notes', content }),
});
if (!res.ok) return null;
const data = await res.json() as { id: number };
return data.id;
} catch {
return null;
}
}
async function updateDbNote(id: number, content: string): Promise<boolean> {
try {
const res = await fetch(`/api/dm/notes?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content }),
});
return res.ok;
} catch {
return false;
}
}
async function loadSavedNotes() {
if (isPro) {
const note = await loadDbNotes();
if (note) {
dbNoteId = note.id;
textarea.value = note.content ?? '';
} else {
const newId = await createDbNote('');
if (newId) {
dbNoteId = newId;
textarea.value = '';
} else {
textarea.value = loadNotes();
}
}
} else {
const saved = loadNotes();
textarea.value = saved;
}
updateCharCount();
}
function handleInput() {
async function handleInput() {
updateCharCount();
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
saveNotes(textarea.value);
showSavedIndicator();
debounceTimer = setTimeout(async () => {
if (isPro) {
if (dbNoteId) {
const ok = await updateDbNote(dbNoteId, textarea.value);
if (ok) {
showSavedIndicator();
} else {
saveNotes(textarea.value);
showErrorIndicator('Ошибка сохранения');
}
} else {
const newId = await createDbNote(textarea.value);
if (newId) {
dbNoteId = newId;
showSavedIndicator();
} else {
saveNotes(textarea.value);
showErrorIndicator('Ошибка сохранения');
}
}
} else {
saveNotes(textarea.value);
showSavedIndicator();
}
}, 500);
}
function handleClear() {
async function handleClear() {
textarea.value = '';
clearNotes();
if (isPro && dbNoteId) {
const ok = await updateDbNote(dbNoteId, '');
if (!ok) {
clearNotes();
}
} else {
clearNotes();
}
updateCharCount();
savedIndicator.classList.add('opacity-0');
if (hideIndicatorTimer) clearTimeout(hideIndicatorTimer);
@@ -99,9 +206,12 @@ import { dmTranslations as T } from '../../i18n/dm-translations';
function handleStorageChange(e: StorageEvent) {
if (e.key === 'dm-notes' && e.newValue !== null) {
textarea.value = e.newValue;
updateCharCount();
showSavedIndicator('Обновлено');
// Only apply localStorage changes if not PRO (or if PRO but DB failed)
if (!isPro) {
textarea.value = e.newValue;
updateCharCount();
showSavedIndicator('Обновлено');
}
}
}
+219 -70
View File
@@ -31,6 +31,30 @@ import DmButton from "./DmButton.astro";
>
Заклинания
</button>
<button
id="o5-tab-equipment"
type="button"
role="tab"
aria-selected="false"
aria-controls="o5-panel-equipment"
class="flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-tab="equipment"
data-testid="ref-tab-equipment"
>
Снаряжение
</button>
<button
id="o5-tab-magicitems"
type="button"
role="tab"
aria-selected="false"
aria-controls="o5-panel-magicitems"
class="flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-tab="magicitems"
data-testid="ref-tab-magicitems"
>
Магические
</button>
</div>
<!-- Search and filters -->
@@ -171,6 +195,7 @@ import DmButton from "./DmButton.astro";
<script>
import { Open5eUIManager } from "@/lib/client/open5e-ui";
import { fetchTranslation } from "@/lib/client/translation";
const CR_OPTIONS = [
{ value: "", label: "Все ОП" },
@@ -224,48 +249,41 @@ import DmButton from "./DmButton.astro";
desc?: string;
}
interface EquipmentItem {
key: string;
name: string;
category?: string;
cost?: string | number | Record<string, unknown>;
weight?: string | number;
damage?: string | Record<string, unknown>;
properties?: string[] | Record<string, unknown>[];
desc?: string;
}
interface MagicItem {
key: string;
name: string;
type?: string;
rarity?: string;
requires_attunement?: string;
desc?: string;
}
type AnyItem = Monster | Spell | EquipmentItem | MagicItem;
type ItemType = "creature" | "spell" | "equipment" | "magicitem";
function escapeHtml(str: string): string {
const div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
const TRANSLATION_CACHE_KEY = "o5-translations";
function loadTranslationCache(): Record<string, Monster | Spell> {
try {
const raw = localStorage.getItem(TRANSLATION_CACHE_KEY);
return raw ? JSON.parse(raw) : {};
} catch {
return {};
}
}
function saveTranslationCache(cache: Record<string, Monster | Spell>) {
try {
localStorage.setItem(TRANSLATION_CACHE_KEY, JSON.stringify(cache));
} catch {
/* ignore quota errors */
}
}
function getCachedTranslation(key: string, type: "creature" | "spell"): Monster | Spell | null {
const cache = loadTranslationCache();
return cache[`${type}:${key}`] ?? null;
}
function setCachedTranslation(key: string, type: "creature" | "spell", data: Monster | Spell) {
const cache = loadTranslationCache();
cache[`${type}:${key}`] = data;
saveTranslationCache(cache);
}
function mergeWithTranslation<T extends Monster | Spell>(item: T, translated: T | null): T {
function mergeWithTranslation<T extends AnyItem>(item: T, translated: Record<string, unknown> | null): T {
if (!translated) return item;
return { ...item, ...translated };
return { ...item, ...translated } as T;
}
function translateButtonHtml(key: string, type: "creature" | "spell", isTranslated: boolean): string {
function translateButtonHtml(key: string, type: ItemType, isTranslated: boolean): string {
const label = isTranslated ? "Переведено" : "Перевести";
const disabled = isTranslated ? "disabled" : "";
return `
@@ -368,6 +386,91 @@ import DmButton from "./DmButton.astro";
return html;
}
function formatCost(cost: EquipmentItem["cost"]): string {
if (cost == null) return "—";
if (typeof cost === "string" || typeof cost === "number") return String(cost);
if (typeof cost === "object") {
const quantity = (cost as Record<string, unknown>).quantity;
const unit = (cost as Record<string, unknown>).unit;
if (quantity != null && unit != null) return `${quantity} ${unit}`;
}
return "—";
}
function renderEquipmentCard(item: EquipmentItem): string {
const cost = formatCost(item.cost);
return `
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5 hover:border-[var(--border-gold)] hover:-translate-y-0.5 transition-all duration-[var(--transition-base)] group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-key="${item.key}" role="button" tabindex="0" data-testid="ref-result-card">
<div class="min-w-0">
<p class="font-semibold text-[var(--gold)] group-hover:text-[var(--gold-hover)] transition-colors truncate">
${escapeHtml(item.name)}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
${escapeHtml(item.category || "—")} · ${escapeHtml(cost)}
</p>
</div>
<div class="shrink-0 text-right">
<p class="text-xs text-[var(--text-secondary)]">Вес ${escapeHtml(String(item.weight ?? "—"))}</p>
</div>
</div>
`;
}
function renderEquipmentDetail(item: EquipmentItem, isTranslated: boolean): string {
const cost = formatCost(item.cost);
let html = `
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(item.name)}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(item.category || "—")}</p>
${translateButtonHtml(item.key, "equipment", isTranslated)}
<div class="grid grid-cols-2 gap-3 mt-4">
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(cost)}</p>
<p class="text-xs text-[var(--text-secondary)]">Стоимость</p>
</div>
<div class="rounded-lg bg-[var(--bg-secondary)] p-3 text-center">
<p class="text-sm font-bold text-[var(--accent)]">${escapeHtml(String(item.weight ?? "—"))}</p>
<p class="text-xs text-[var(--text-secondary)]">Вес</p>
</div>
</div>
`;
if (item.desc) {
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(item.desc)}</p></div>`;
}
return html;
}
function renderMagicItemCard(item: MagicItem): string {
return `
<div class="flex items-center justify-between gap-3 cursor-pointer rounded-xl bg-[var(--bg-card)] border border-[var(--border-gold-strong)] shadow-lg shadow-black/20 p-5 hover:border-[var(--border-gold)] hover:-translate-y-0.5 transition-all duration-[var(--transition-base)] group focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]"
data-key="${item.key}" role="button" tabindex="0" data-testid="ref-result-card">
<div class="min-w-0">
<p class="font-semibold text-[var(--gold)] group-hover:text-[var(--gold-hover)] transition-colors truncate">
${escapeHtml(item.name)}
</p>
<p class="text-xs text-[var(--text-secondary)] mt-0.5">
${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}
</p>
</div>
</div>
`;
}
function renderMagicItemDetail(item: MagicItem, isTranslated: boolean): string {
let html = `
<h2 class="text-xl font-bold text-[var(--text-primary)] pr-8">${escapeHtml(item.name)}</h2>
<p class="text-sm text-[var(--text-secondary)] mt-1">${escapeHtml(item.type || "—")} · ${escapeHtml(item.rarity || "—")}</p>
${translateButtonHtml(item.key, "magicitem", isTranslated)}
`;
if (item.requires_attunement) {
html += `<p class="text-sm text-[var(--gold)] mt-2 italic">Требует настройки: ${escapeHtml(item.requires_attunement)}</p>`;
}
if (item.desc) {
html += `<div class="mt-4"><p class="text-sm text-[var(--text-secondary)] whitespace-pre-line">${escapeHtml(item.desc)}</p></div>`;
}
return html;
}
function renderSpellDetail(spell: Spell, isTranslated: boolean): string {
const level = Open5eUIManager.formatSpellLevel(spell.level ?? 0);
let html = `
@@ -420,27 +523,53 @@ import DmButton from "./DmButton.astro";
const detailBg = container.querySelector("#o5-detail-bg")!;
const tabMonsters = container.querySelector("#o5-tab-monsters")!;
const tabSpells = container.querySelector("#o5-tab-spells")!;
const tabEquipment = container.querySelector("#o5-tab-equipment")!;
const tabMagicitems = container.querySelector("#o5-tab-magicitems")!;
const retryBtn = container.querySelector("#o5-retry")!;
const tabTypeMap: Record<string, ItemType> = {
monsters: "creature",
spells: "spell",
equipment: "equipment",
magicitems: "magicitem",
};
const manager = new Open5eUIManager(render);
function buildFilterOptions() {
const options = manager.state.tab === "monsters" ? CR_OPTIONS : LEVEL_OPTIONS;
filterSelect.innerHTML = options
.map((o) => `<option value="${o.value}">${o.label}</option>`)
.join("");
const tab = manager.state.tab;
if (tab === "monsters") {
filterSelect.innerHTML = CR_OPTIONS.map(
(o) => `<option value="${o.value}">${o.label}</option>`,
).join("");
filterSelect.classList.remove("hidden");
} else if (tab === "spells") {
filterSelect.innerHTML = LEVEL_OPTIONS.map(
(o) => `<option value="${o.value}">${o.label}</option>`,
).join("");
filterSelect.classList.remove("hidden");
} else {
filterSelect.innerHTML = "";
filterSelect.classList.add("hidden");
}
}
function updateTabs() {
const isMonsters = manager.state.tab === "monsters";
tabMonsters.setAttribute("aria-selected", String(isMonsters));
tabSpells.setAttribute("aria-selected", String(!isMonsters));
const tab = manager.state.tab;
const activeClasses = "flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-[var(--gold)] text-[var(--gold)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
const inactiveClasses = "flex-1 pb-2.5 text-sm font-semibold transition-colors border-b-2 border-transparent text-[var(--text-muted)] hover:text-[var(--text-secondary)] focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)]";
tabMonsters.className = isMonsters ? activeClasses : inactiveClasses;
tabSpells.className = isMonsters ? inactiveClasses : activeClasses;
const tabs: Array<[Element, string]> = [
[tabMonsters, "monsters"],
[tabSpells, "spells"],
[tabEquipment, "equipment"],
[tabMagicitems, "magicitems"],
];
for (const [el, tabName] of tabs) {
const isActive = tab === tabName;
el.setAttribute("aria-selected", String(isActive));
el.className = isActive ? activeClasses : inactiveClasses;
}
}
function render() {
@@ -454,16 +583,34 @@ import DmButton from "./DmButton.astro";
errorEl.classList.toggle("hidden", !state.error);
if (state.error) errorTextEl.textContent = state.error;
emptyEl.classList.toggle("hidden", state.results.length > 0 || state.loading || !!state.error);
resultsContainer.setAttribute("data-testid", state.tab === "monsters" ? "ref-monsters-list" : "ref-spells-list");
const listTestid: Record<string, string> = {
monsters: "ref-monsters-list",
spells: "ref-spells-list",
equipment: "ref-equipment-list",
magicitems: "ref-magicitems-list",
};
resultsContainer.setAttribute("data-testid", listTestid[state.tab] ?? "ref-results-list");
const paginated = manager.getPaginatedResults();
resultsContainer.innerHTML = "";
function renderCard(tab: typeof state.tab, item: AnyItem): string {
if (tab === "monsters") return renderMonsterCard(item as Monster);
if (tab === "spells") return renderSpellCard(item as Spell);
if (tab === "equipment") return renderEquipmentCard(item as EquipmentItem);
return renderMagicItemCard(item as MagicItem);
}
function renderDetail(tab: typeof state.tab, item: AnyItem, translated: boolean): string {
if (tab === "monsters") return renderMonsterDetail(item as Monster, translated);
if (tab === "spells") return renderSpellDetail(item as Spell, translated);
if (tab === "equipment") return renderEquipmentDetail(item as EquipmentItem, translated);
return renderMagicItemDetail(item as MagicItem, translated);
}
if (paginated.length > 0) {
for (const item of paginated) {
const cardHtml = state.tab === "monsters"
? renderMonsterCard(item)
: renderSpellCard(item);
const cardHtml = renderCard(state.tab, item);
const wrapper = document.createElement("div");
wrapper.innerHTML = cardHtml;
const card = wrapper.firstElementChild as HTMLElement;
@@ -491,13 +638,11 @@ import DmButton from "./DmButton.astro";
}
if (state.selectedItem) {
const type = state.tab === "monsters" ? "creature" : "spell";
const type = tabTypeMap[state.tab];
const cached = getCachedTranslation(state.selectedItem.key, type);
const isTranslated = cached !== null;
const item = mergeWithTranslation(state.selectedItem, cached);
detailContent.innerHTML = state.tab === "monsters"
? renderMonsterDetail(item as Monster, isTranslated)
: renderSpellDetail(item as Spell, isTranslated);
const item = mergeWithTranslation(state.selectedItem as AnyItem, cached);
detailContent.innerHTML = renderDetail(state.tab, item, isTranslated);
detailOverlay.classList.remove("hidden");
detailClose.focus();
} else {
@@ -517,6 +662,14 @@ import DmButton from "./DmButton.astro";
manager.setTab("spells");
buildFilterOptions();
});
tabEquipment.addEventListener("click", () => {
manager.setTab("equipment");
buildFilterOptions();
});
tabMagicitems.addEventListener("click", () => {
manager.setTab("magicitems");
buildFilterOptions();
});
searchInput.addEventListener("input", () => {
manager.setQueryDebounced(searchInput.value.trim());
@@ -553,39 +706,35 @@ import DmButton from "./DmButton.astro";
const btn = (e.target as HTMLElement).closest("[data-translate-btn]") as HTMLButtonElement | null;
if (!btn || btn.disabled) return;
const slug = btn.dataset.slug;
const type = btn.dataset.type as "creature" | "spell";
const type = btn.dataset.type as ItemType;
if (!slug || !type) return;
handleTranslate(slug, type, btn);
});
async function handleTranslate(slug: string, type: "creature" | "spell", btn: HTMLButtonElement) {
function renderSelectedDetail(item: AnyItem, isTranslated: boolean): string {
const tab = manager.state.tab;
if (tab === "monsters") return renderMonsterDetail(item as Monster, isTranslated);
if (tab === "spells") return renderSpellDetail(item as Spell, isTranslated);
if (tab === "equipment") return renderEquipmentDetail(item as EquipmentItem, isTranslated);
return renderMagicItemDetail(item as MagicItem, isTranslated);
}
async function handleTranslate(slug: string, type: ItemType, btn: HTMLButtonElement) {
const originalText = btn.textContent?.trim() ?? "Перевести";
btn.textContent = "Перевод...";
btn.disabled = true;
try {
const res = await fetch(`/api/dm/translate?slug=${encodeURIComponent(slug)}&type=${type}`);
const data = await res.json().catch(() => ({}));
if (!res.ok || data.error) {
throw new Error(data.error || "Ошибка перевода");
}
const translated = data.translated as Monster | Spell;
setCachedTranslation(slug, type, translated);
const translated = await fetchTranslation(slug, type);
btn.textContent = "Переведено";
btn.disabled = true;
// Re-render detail with translation
const state = manager.state;
if (state.selectedItem && state.selectedItem.key === slug) {
const cached = getCachedTranslation(slug, type);
const isTranslated = cached !== null;
const item = mergeWithTranslation(state.selectedItem, cached);
detailContent.innerHTML = state.tab === "monsters"
? renderMonsterDetail(item as Monster, isTranslated)
: renderSpellDetail(item as Spell, isTranslated);
const isTranslated = translated !== null;
const item = mergeWithTranslation(state.selectedItem as AnyItem, translated);
detailContent.innerHTML = renderSelectedDetail(item, isTranslated);
}
} catch {
btn.textContent = "Ошибка перевода";
+12 -9
View File
@@ -1,21 +1,24 @@
import { drizzle } from 'drizzle-orm/node-postgres';
import { migrate } from 'drizzle-orm/node-postgres/migrator';
import { Pool } from 'pg';
import pg from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
console.error('Migration aborted: DATABASE_URL is not set');
process.exit(1);
}
const pool = new pg.Pool({ connectionString });
const db = drizzle(pool);
async function runMigrations() {
try {
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './drizzle' });
console.log('Migrations completed.');
} catch (err) {
console.error('Migration failed:', err);
await pool.end();
process.exit(1);
}
runMigrations().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});
await pool.end();
+148
View File
@@ -88,6 +88,134 @@ export const dmTranslations = {
aiTitle: "Генератор ИИ",
aiSignInCta: "Войдите, чтобы использовать ИИ",
aiPlaceholder: "Генератор ИИ появится здесь",
aiQuotaExhausted: "Лимит ИИ-генераций исчерпан.",
aiQuotaResetsIn: "Новые генерации будут доступны через {minutes} мин.",
aiQuotaUpgradeHint: "Хотите больше? Поддержите проект на Boosty — получите PRO с 100 генерациями/час.",
// 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 segmented control
aiKindNpc: "NPC",
aiKindSpell: "Заклинание",
// AI Spell Form
spellFormName: "Подсказка имени (опционально)",
spellFormLevel: "Уровень заклинания",
spellFormSchool: "Школа",
spellFormClasses: "Классы (опционально)",
spellFormTone: "Тон",
spellFormSubmit: "Сгенерировать заклинание",
spellGenerating: "Генерация...",
spellGenerate: "Сгенерировать заклинание",
spellErrorGeneric: "Ошибка генерации заклинания.",
spellErrorRateLimit: "Превышен лимит генераций. Попробуйте через {minutes} мин.",
// Spell schools
schoolAbjuration: "Ограждение",
schoolConjuration: "Призыв",
schoolDivination: "Прорицание",
schoolEnchantment: "Очарование",
schoolEvocation: "Воплощение",
schoolIllusion: "Иллюзия",
schoolNecromancy: "Некромантия",
schoolTransmutation: "Преобразование",
// Spell result card
spellLabelLevel: "Уровень",
spellLabelSchool: "Школа",
spellLabelCastingTime: "Время накладывания",
spellLabelRange: "Дистанция",
spellLabelComponents: "Компоненты",
spellLabelDuration: "Длительность",
spellLabelClasses: "Классы",
spellLabelDescription: "Описание",
spellLabelHigherLevels: "На больших уровнях",
spellCopyJson: "Копировать JSON",
spellCopied: "Скопировано",
spellRegenerate: "Перегенерировать",
spellCantripLabel: "Заговор",
// AI History
historyTitle: "История генераций",
historyEmptyPro: "Нет сохранённых NPC",
historyEmptyFree: "История сохраняется только для PRO",
historyUpgradeCta: "Перейти на PRO",
// NPC result card
npcName: "Имя",
npcHp: "ХП",
npcAc: "КЗ",
npcCr: "ОП",
npcSpeed: "Скорость",
npcAppearance: "Внешность",
npcTrait: "Черта",
npcMotivation: "Мотивация",
npcSecret: "Тайна",
npcHistory: "История",
npcCopyJson: "Копировать JSON",
npcCopied: "Скопировано",
npcRegenerate: "Перегенерировать",
saveSession: "Сохранить сессию",
newSession: "Новая сессия",
sessionNamePlaceholder: "Название сессии",
noSavedSessions: "Нет сохранённых сессий",
sessionSaved: "Сессия сохранена",
saveError: "Ошибка сохранения",
loadSessionError: "Ошибка загрузки сессии",
deleteSessionConfirm: "Удалить сессию?",
noteSaveError: "Ошибка сохранения заметок",
noteLoadError: "Ошибка загрузки заметок",
// Auth
loginVk: "Войти через VK",
@@ -95,6 +223,26 @@ export const dmTranslations = {
logout: "Выйти",
badgeFree: "FREE",
badgePro: "PRO",
// FREE → PRO import flow
importTitle: "Импортировать локальные данные?",
importPrompt:
"Вы получили доступ PRO. Хотите перенести существующие заметки и сессии инициативы в облако?",
importBtn: "Импортировать",
importSkip: "Пропустить",
importItemNotes: "Заметки",
importItemInitiative: "Участников боя",
importChars: "симв.",
importNotesProgress: "Перенос заметок…",
importInitiativeProgress: "Перенос инициативы…",
importInitiativeName: "Импорт",
importDone: "Готово",
importError: "Ошибка импорта. Локальные данные сохранены.",
importNotesConflict:
"В облаке уже есть заметки. Что сделать с локальной копией?",
importConflictKeepBoth: "Сохранить обе",
importConflictReplace: "Заменить",
importConflictSkip: "Пропустить заметки",
} as const;
export type DmT = typeof dmTranslations;
+2
View File
@@ -83,6 +83,7 @@ export const translations = {
catUtility: "Utility",
dmDashboard: "DM Dashboard",
dmDashboardDesc: "Tools for dungeon masters — dice, initiative, reference, and notes.",
wip: "Work in progress",
notFoundTitle: "Page Not Found",
notFoundMessage:
@@ -196,6 +197,7 @@ export const translations = {
catUtility: "Утилиты",
dmDashboard: "Для мастера",
dmDashboardDesc: "Инструменты для мастера подземелий — кубики, инициатива, справочник и заметки.",
wip: "В разработке",
notFoundTitle: "Страница не найдена",
notFoundMessage:
+181 -1
View File
@@ -1,10 +1,14 @@
import { z } from "zod";
import {
npcParamsSchema,
npcResultSchema,
spellParamsSchema,
spellResultSchema,
type NPCParams,
type NPCResult,
type Open5eMonster,
type SpellParams,
type SpellResult,
type Open5eSpell,
} from "./types";
const KIMI_API_URL = "https://api.moonshot.ai/v1/chat/completions";
@@ -120,6 +124,182 @@ export class KimiClientError extends Error {
}
}
const spellJsonSchema = {
type: "object" as const,
properties: {
name: { type: "string" },
level: { type: "integer" },
school: { type: "string" },
casting_time: { type: "string" },
range: { type: "string" },
components: { type: "string" },
duration: { type: "string" },
classes: { type: "string" },
description: { type: "string" },
higher_levels: { type: "string" },
},
required: [
"name",
"level",
"school",
"casting_time",
"range",
"components",
"duration",
"classes",
"description",
],
};
function buildSpellSystemPrompt(): string {
return [
"You are a Dungeons & Dragons 5e spell designer.",
"You MUST respond with valid JSON only. Do NOT wrap the response in markdown code blocks.",
"Do NOT include any explanatory text outside the JSON object.",
"The JSON must exactly match the provided schema.",
].join(" ");
}
function formatSpellReference(spell: Open5eSpell): string {
const parts: string[] = [`Open5e Reference Spell: ${spell.name}`];
if (spell.level !== undefined) parts.push(`Level: ${spell.level}`);
if (spell.school) parts.push(`School: ${spell.school}`);
if (spell.casting_time) parts.push(`Casting time: ${spell.casting_time}`);
if (spell.range) parts.push(`Range: ${spell.range}`);
if (spell.duration) parts.push(`Duration: ${spell.duration}`);
if (spell.components) parts.push(`Components: ${spell.components}`);
if (spell.desc) {
const desc = Array.isArray(spell.desc) ? spell.desc.join(" ") : spell.desc;
parts.push(`Description: ${desc}`);
}
return parts.join("\n");
}
function buildSpellUserPrompt(params: SpellParams, reference?: Open5eSpell): string {
const lines: string[] = [
`Generate a D&D 5e spell with the following parameters:`,
`- Level: ${params.level}`,
`- School: ${params.school}`,
];
if (params.classes) lines.push(`- Classes: ${params.classes}`);
if (params.tone) lines.push(`- Tone: ${params.tone}`);
if (params.name) lines.push(`- Suggested name: ${params.name}`);
if (reference) {
lines.push("");
lines.push("Use the following Open5e spell as a balance reference. Do not exceed its power band:");
lines.push(formatSpellReference(reference));
}
lines.push("");
lines.push("Respond with a single JSON object matching this schema:");
lines.push(JSON.stringify(spellJsonSchema, null, 2));
return lines.join("\n");
}
export async function generateSpell(
params: SpellParams,
reference?: Open5eSpell
): Promise<SpellResult> {
spellParamsSchema.parse(params);
const apiKey = process.env.KIMI_API_KEY;
if (!apiKey) {
throw new KimiClientError("KIMI_API_KEY is not configured");
}
const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(KIMI_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: KIMI_MODEL,
messages: [
{ role: "system", content: buildSpellSystemPrompt() },
{ role: "user", content: buildSpellUserPrompt(params, reference) },
],
response_format: {
type: "json_schema",
json_schema: {
name: "spell_result",
strict: true,
schema: spellJsonSchema,
},
},
temperature: 0.7,
}),
signal: abortController.signal,
});
if (!response.ok) {
const body = await response.text().catch(() => undefined);
if (response.status === 429) {
throw new KimiClientError("Kimi API rate limit exceeded", response.status, body);
}
throw new KimiClientError(
`Kimi API error: ${response.status} ${response.statusText}`,
response.status,
body,
);
}
const responseText = await response.text();
let data: {
choices?: Array<{ message?: { content?: string | null } }>;
error?: { message?: string };
};
try {
data = JSON.parse(responseText);
} catch {
throw new KimiClientError("Kimi API returned invalid JSON", undefined, responseText);
}
if (data.error?.message) {
throw new KimiClientError(`Kimi API error: ${data.error.message}`);
}
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new KimiClientError("Kimi API returned empty content");
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new KimiClientError("Kimi API returned invalid JSON", undefined, content);
}
const validation = spellResultSchema.safeParse(parsed);
if (!validation.success) {
throw new KimiClientError(
`Kimi API spell validation failed: ${validation.error.message}`,
undefined,
content,
);
}
return validation.data;
} catch (error) {
if (error instanceof KimiClientError) throw error;
if (error instanceof Error && error.name === "AbortError") {
throw new KimiClientError("Kimi API request timed out after 10s");
}
throw new KimiClientError(
error instanceof Error ? error.message : "Unknown Kimi API error",
);
} finally {
clearTimeout(timeoutId);
}
}
export async function generateNPC(
params: NPCParams,
reference?: Open5eMonster
+131 -6
View File
@@ -1,6 +1,13 @@
import { z } from "zod";
import { npcResultSchema, type NPCResult, type NPCParams, type Open5eMonster } from "@/lib/ai/types";
import type { Monster } from "@/lib/open5e/client";
import {
npcResultSchema,
spellResultSchema,
type NPCResult,
type NPCParams,
type Open5eMonster,
type SpellResult,
type SpellParams,
type Open5eSpell,
} from "@/lib/ai/types";
const OPENROUTER_API_URL = "https://openrouter.ai/api/v1/chat/completions";
const DEFAULT_MODEL = "llama-3.3-70b:free";
@@ -47,9 +54,51 @@ function buildUserPrompt(params: NPCParams, reference?: Open5eMonster): string {
return prompt;
}
function buildSpellSystemPrompt(): string {
return "You are a creative D&D spell designer. Respond with valid JSON only, no markdown, no code fences, no explanatory text.";
}
function buildSpellUserPrompt(params: SpellParams, reference?: Open5eSpell): string {
const schema = JSON.stringify({
name: "string (unique evocative spell name)",
level: "integer 0-9 (0 = cantrip)",
school: "string (Evocation, Abjuration, Conjuration, Divination, Enchantment, Illusion, Necromancy, Transmutation)",
casting_time: "string (e.g. '1 action', '1 bonus action', '1 reaction')",
range: "string (e.g. 'Self', '30 feet', '120 feet')",
components: "string (e.g. 'V, S', 'V, S, M (a pinch of dust)')",
duration: "string (e.g. 'Instantaneous', 'Concentration, up to 1 minute')",
classes: "string (comma-separated: e.g. 'Wizard, Sorcerer')",
description: "string (2-4 sentences, mechanically clear, evocative)",
higher_levels: "string (optional, scaling description; omit or empty if not applicable)",
});
let prompt = `Generate a D&D 5e spell with the following parameters:\n`;
prompt += `- Level: ${params.level}\n`;
prompt += `- School: ${params.school}\n`;
if (params.classes) prompt += `- Classes: ${params.classes}\n`;
if (params.tone) prompt += `- Tone: ${params.tone}\n`;
if (params.name) prompt += `- Suggested name: ${params.name}\n`;
if (reference) {
prompt += `\nUse this reference SRD spell for balance and structure:\n`;
prompt += `- Name: ${reference.name}\n`;
if (reference.level !== undefined) prompt += `- Level: ${reference.level}\n`;
if (reference.school) prompt += `- School: ${reference.school}\n`;
if (reference.casting_time) prompt += `- Casting time: ${reference.casting_time}\n`;
if (reference.range) prompt += `- Range: ${reference.range}\n`;
if (reference.duration) prompt += `- Duration: ${reference.duration}\n`;
prompt += `Stay within the same power band; do not exceed reference damage/effect.\n`;
}
prompt += `\nRespond with valid JSON matching this schema:\n${schema}`;
return prompt;
}
export type Open5eContentType = "creature" | "spell" | "equipment" | "magicitem";
export async function translateOpen5eContent(
content: Record<string, unknown>,
type: "creature" | "spell",
type: Open5eContentType,
model: string = DEFAULT_MODEL
): Promise<Record<string, unknown>> {
const apiKey = process.env.OPENROUTER_API_KEY;
@@ -121,7 +170,7 @@ export async function translateOpen5eContent(
return parsed as Record<string, unknown>;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("OpenRouter request timed out after 10s");
throw new Error("OpenRouter request timed out after 10s", { cause: err });
}
throw err;
} finally {
@@ -197,7 +246,83 @@ export async function generateNPC(
return validated.data;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("OpenRouter request timed out after 10s");
throw new Error("OpenRouter request timed out after 10s", { cause: err });
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}
export async function generateSpell(
params: SpellParams,
reference?: Open5eSpell,
model: string = DEFAULT_MODEL
): Promise<SpellResult> {
const apiKey = process.env.OPENROUTER_API_KEY;
if (!apiKey) {
throw new Error("OPENROUTER_API_KEY is not set");
}
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(OPENROUTER_API_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${apiKey}`,
"HTTP-Referer": process.env.PUBLIC_APP_URL || "https://randify.pro",
"X-Title": "Randify.pro DM Dashboard",
},
body: JSON.stringify({
model,
messages: [
{ role: "system", content: buildSpellSystemPrompt() },
{ role: "user", content: buildSpellUserPrompt(params, reference) },
],
temperature: 0.8,
}),
signal: controller.signal,
});
if (response.status === 429) {
throw new Error("Rate limited by OpenRouter (429)");
}
if (!response.ok) {
const text = await response.text();
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
}
const data = (await response.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
const content = data.choices?.[0]?.message?.content;
if (!content) {
throw new Error("Empty response from OpenRouter");
}
let parsed: unknown;
try {
parsed = JSON.parse(content);
} catch {
throw new Error(`Invalid JSON in OpenRouter response: ${content}`);
}
const validated = spellResultSchema.safeParse(parsed);
if (!validated.success) {
throw new Error(
`Spell schema validation failed: ${validated.error.message}`
);
}
return validated.data;
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
throw new Error("OpenRouter request timed out after 10s", { cause: err });
}
throw err;
} finally {
+40
View File
@@ -29,6 +29,46 @@ export const npcResultSchema = z.object({
export type NPCResult = z.infer<typeof npcResultSchema>;
export const spellParamsSchema = z.object({
name: z.string().optional(),
level: z.number().int().min(0).max(9),
school: z.string(),
classes: z.string().optional(),
tone: z.string().optional(),
});
export type SpellParams = z.infer<typeof spellParamsSchema>;
export const spellResultSchema = z.object({
name: z.string(),
level: z.number().int().min(0).max(9),
school: z.string(),
casting_time: z.string(),
range: z.string(),
components: z.string(),
duration: z.string(),
classes: z.string(),
description: z.string(),
higher_levels: z.string().optional(),
});
export type SpellResult = z.infer<typeof spellResultSchema>;
export interface Open5eSpell {
name: string;
level?: number;
school?: string;
casting_time?: string;
range?: string;
components?: string;
duration?: string;
desc?: string | string[];
higher_level?: string | string[];
ritual?: boolean;
concentration?: boolean;
classes?: Array<{ name: string }> | string[];
}
export interface Open5eMonster {
name: string;
size?: string;
+9 -6
View File
@@ -27,19 +27,22 @@ describe('Auth Environment Validation', () => {
expect(requireEnv('PUBLIC_APP_URL')).toBe(baseEnv.PUBLIC_APP_URL);
});
it('should throw at module load if JWT_SECRET is missing', async () => {
it('should throw on first access if JWT_SECRET is missing', async () => {
delete process.env.JWT_SECRET;
await expect(import('./env')).rejects.toThrow('JWT_SECRET');
const { authEnv } = await import('./env');
expect(() => authEnv.JWT_SECRET).toThrow('JWT_SECRET');
});
it('should throw at module load if PUBLIC_APP_URL is invalid', async () => {
it('should throw on first access if PUBLIC_APP_URL is invalid', async () => {
process.env.PUBLIC_APP_URL = 'not-a-url';
await expect(import('./env')).rejects.toThrow('PUBLIC_APP_URL');
const { authEnv } = await import('./env');
expect(() => authEnv.PUBLIC_APP_URL).toThrow('PUBLIC_APP_URL');
});
it('should throw at module load if VK_CLIENT_ID is missing', async () => {
it('should throw on first access if VK_CLIENT_ID is missing', async () => {
delete process.env.VK_CLIENT_ID;
await expect(import('./env')).rejects.toThrow('VK_CLIENT_ID');
const { authEnv } = await import('./env');
expect(() => authEnv.VK_CLIENT_ID).toThrow('VK_CLIENT_ID');
});
});
+18 -7
View File
@@ -9,20 +9,31 @@ const envSchema = z.object({
PUBLIC_APP_URL: z.string().url('PUBLIC_APP_URL must be a valid URL'),
});
function validateAuthEnv() {
const raw = {
type AuthEnv = z.infer<typeof envSchema>;
let cached: AuthEnv | null = null;
function loadAuthEnv(): AuthEnv {
if (cached) return cached;
cached = envSchema.parse({
JWT_SECRET: process.env.JWT_SECRET,
VK_CLIENT_ID: process.env.VK_CLIENT_ID,
VK_CLIENT_SECRET: process.env.VK_CLIENT_SECRET,
YANDEX_CLIENT_ID: process.env.YANDEX_CLIENT_ID,
YANDEX_CLIENT_SECRET: process.env.YANDEX_CLIENT_SECRET,
PUBLIC_APP_URL: process.env.PUBLIC_APP_URL,
};
return envSchema.parse(raw);
});
return cached;
}
export const authEnv = validateAuthEnv();
// Lazy proxy: validation fires only when a key is actually read at runtime,
// not when the module is first imported (which happens during `astro build`).
export const authEnv = new Proxy({} as AuthEnv, {
get(_target, key: string) {
return loadAuthEnv()[key as keyof AuthEnv];
},
}) as AuthEnv;
export function requireEnv(name: keyof typeof envSchema.shape): string {
return authEnv[name];
export function requireEnv(name: keyof AuthEnv): string {
return loadAuthEnv()[name];
}
+9 -3
View File
@@ -2,18 +2,24 @@ import { SignJWT, jwtVerify } from 'jose';
import type { APIContext } from 'astro';
import { authEnv } from './env';
const SECRET = new TextEncoder().encode(authEnv.JWT_SECRET);
let secretCache: Uint8Array | null = null;
function getSecret(): Uint8Array {
if (!secretCache) {
secretCache = new TextEncoder().encode(authEnv.JWT_SECRET);
}
return secretCache;
}
export async function createToken(userId: string): Promise<string> {
return new SignJWT({ sub: userId })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(SECRET);
.sign(getSecret());
}
export async function verifyToken(token: string): Promise<{ sub: string }> {
const { payload } = await jwtVerify(token, SECRET, {
const { payload } = await jwtVerify(token, getSecret(), {
clockTolerance: 60,
});
if (!payload.sub || typeof payload.sub !== 'string') {
+15 -7
View File
@@ -1,7 +1,13 @@
import { SignJWT, jwtVerify } from 'jose';
import { authEnv } from './env';
const JWT_SECRET = new TextEncoder().encode(authEnv.JWT_SECRET);
let secretCache: Uint8Array | null = null;
function getJwtSecret(): Uint8Array {
if (!secretCache) {
secretCache = new TextEncoder().encode(authEnv.JWT_SECRET);
}
return secretCache;
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(64);
@@ -30,17 +36,19 @@ export function generateState(): string {
return base64UrlEncode(array);
}
// Lazy getters so env validation doesn't fire at module import time
// (which happens during `astro build` when secrets are absent).
export const vkOAuthConfig = {
clientId: authEnv.VK_CLIENT_ID,
clientSecret: authEnv.VK_CLIENT_SECRET,
get clientId() { return authEnv.VK_CLIENT_ID; },
get clientSecret() { return authEnv.VK_CLIENT_SECRET; },
authUrl: 'https://id.vk.ru/authorize',
tokenUrl: 'https://id.vk.ru/oauth2/auth',
scope: 'email phone',
};
export const yandexOAuthConfig = {
clientId: authEnv.YANDEX_CLIENT_ID,
clientSecret: authEnv.YANDEX_CLIENT_SECRET,
get clientId() { return authEnv.YANDEX_CLIENT_ID; },
get clientSecret() { return authEnv.YANDEX_CLIENT_SECRET; },
authUrl: 'https://oauth.yandex.com/authorize',
tokenUrl: 'https://oauth.yandex.com/token',
scope: 'login:email login:info login:avatar',
@@ -51,12 +59,12 @@ export async function createSessionToken(userId: number): Promise<string> {
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('7d')
.sign(JWT_SECRET);
.sign(getJwtSecret());
}
export async function verifySessionToken(token: string): Promise<{ userId: number } | null> {
try {
const { payload } = await jwtVerify(token, JWT_SECRET, { clockTolerance: 60 });
const { payload } = await jwtVerify(token, getJwtSecret(), { clockTolerance: 60 });
if (!payload.sub) return null;
return { userId: Number(payload.sub) };
} catch {
+67
View File
@@ -0,0 +1,67 @@
const NOTES_KEY = "dm-notes";
const INITIATIVE_KEY = "dm-initiative";
const LEGACY_INITIATIVE_KEY = "it-combatants";
const ASKED_FLAG = "dm-import-asked";
export interface LocalCombatant {
id: string;
name: string;
initiative?: number;
modifier?: number;
hp?: number;
}
export function getLocalNotes(): string {
try {
return localStorage.getItem(NOTES_KEY) ?? "";
} catch {
return "";
}
}
export function getLocalInitiative(): LocalCombatant[] {
try {
const raw =
sessionStorage.getItem(INITIATIVE_KEY) ??
sessionStorage.getItem(LEGACY_INITIATIVE_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw) as Array<Partial<LocalCombatant>>;
return parsed
.filter((c) => typeof c.name === "string" && c.name.length > 0)
.map((c, i) => ({
id: typeof c.id === "string" ? c.id : `legacy-${i}`,
name: c.name as string,
initiative: typeof c.initiative === "number" ? c.initiative : undefined,
modifier: typeof c.modifier === "number" ? c.modifier : undefined,
hp: typeof c.hp === "number" ? c.hp : undefined,
}));
} catch {
return [];
}
}
export function hasLocalData(): boolean {
return getLocalNotes().trim().length > 0 || getLocalInitiative().length > 0;
}
export function wasAsked(): boolean {
try {
return localStorage.getItem(ASKED_FLAG) === "1";
} catch {
return true;
}
}
export function markAsked(): void {
try {
localStorage.setItem(ASKED_FLAG, "1");
} catch {
/* ignore */
}
}
export function shouldShowImport(tier: string, userId: number | undefined): boolean {
if (tier !== "pro" || !userId) return false;
if (wasAsked()) return false;
return hasLocalData();
}
+15 -3
View File
@@ -1,11 +1,15 @@
import {
searchMonsters,
searchSpells,
searchEquipment,
searchMagicItems,
getMonster,
getSpell,
getEquipmentItem,
getMagicItem,
} from "@/lib/open5e/client";
export type Tab = "monsters" | "spells";
export type Tab = "monsters" | "spells" | "equipment" | "magicitems";
export interface Open5eItem {
key: string;
@@ -101,11 +105,15 @@ export class Open5eUIManager {
? { challenge_rating_decimal: this.state.monsterCrFilter }
: undefined;
results = await searchMonsters(this.state.query, filters);
} else {
} else if (this.state.tab === "spells") {
const filters = this.state.spellLevelFilter
? { level: this.state.spellLevelFilter }
: undefined;
results = await searchSpells(this.state.query, filters);
} else if (this.state.tab === "equipment") {
results = await searchEquipment(this.state.query);
} else {
results = await searchMagicItems(this.state.query);
}
this.state.results = results;
this.state.totalPages = Math.max(
@@ -148,8 +156,12 @@ export class Open5eUIManager {
try {
if (this.state.tab === "monsters") {
this.state.selectedItem = await getMonster(key);
} else {
} else if (this.state.tab === "spells") {
this.state.selectedItem = await getSpell(key);
} else if (this.state.tab === "equipment") {
this.state.selectedItem = await getEquipmentItem(key);
} else {
this.state.selectedItem = await getMagicItem(key);
}
} catch (err: unknown) {
this.state.error = err instanceof Error ? err.message : "Failed to load details";
+78
View File
@@ -0,0 +1,78 @@
export type TranslationType = "creature" | "spell" | "equipment" | "magicitem";
export interface TranslationEntry {
slug: string;
type: TranslationType;
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: TranslationType,
): Record<string, unknown> | null {
return CACHE.get(`${type}:${slug}`)?.data ?? null;
}
export function setCachedTranslation(
slug: string,
type: TranslationType,
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: TranslationType,
): 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;
}
+102
View File
@@ -170,3 +170,105 @@ export async function getSpell(key: string): Promise<Spell> {
throw err;
}
}
export interface EquipmentItem {
name: string;
key: string;
category?: string;
cost?: string | number | Record<string, unknown>;
weight?: string | number;
damage?: string | Record<string, unknown>;
properties?: string[] | Record<string, unknown>[];
desc?: string;
}
export async function searchEquipment(
query: string,
filters?: SearchFilters
): Promise<EquipmentItem[]> {
const cacheKey = `open5e:v2:equipment:${query}`;
const cached = getCached<EquipmentItem[]>(cacheKey);
if (cached) return cached;
const url = buildUrl("equipment/", query, filters);
const urlObj = new URL(url);
urlObj.searchParams.set("fields", "name,key,category,cost,weight");
try {
const data = await apiFetch<{ results: EquipmentItem[] }>(urlObj.toString());
setCached(cacheKey, data.results);
return data.results;
} catch (err) {
const stale = getCached<EquipmentItem[]>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function getEquipmentItem(key: string): Promise<EquipmentItem> {
const cacheKey = `open5e:v2:equipment:item:${key}`;
const cached = getCached<EquipmentItem>(cacheKey);
if (cached) return cached;
const url = `${API_BASE}equipment/${key}/`;
try {
const data = await apiFetch<EquipmentItem>(url);
setCached(cacheKey, data);
return data;
} catch (err) {
const stale = getCached<EquipmentItem>(cacheKey);
if (stale) return stale;
throw err;
}
}
export interface MagicItem {
name: string;
key: string;
type?: string;
rarity?: string;
requires_attunement?: string;
desc?: string;
}
export async function searchMagicItems(
query: string,
filters?: SearchFilters
): Promise<MagicItem[]> {
const cacheKey = `open5e:v2:magicitems:${query}`;
const cached = getCached<MagicItem[]>(cacheKey);
if (cached) return cached;
const url = buildUrl("magicitems/", query, filters);
const urlObj = new URL(url);
urlObj.searchParams.set("fields", "name,key,type,rarity");
try {
const data = await apiFetch<{ results: MagicItem[] }>(urlObj.toString());
setCached(cacheKey, data.results);
return data.results;
} catch (err) {
const stale = getCached<MagicItem[]>(cacheKey);
if (stale) return stale;
throw err;
}
}
export async function getMagicItem(key: string): Promise<MagicItem> {
const cacheKey = `open5e:v2:magicitem:${key}`;
const cached = getCached<MagicItem>(cacheKey);
if (cached) return cached;
const url = `${API_BASE}magicitems/${key}/`;
try {
const data = await apiFetch<MagicItem>(url);
setCached(cacheKey, data);
return data;
} catch (err) {
const stale = getCached<MagicItem>(cacheKey);
if (stale) return stale;
throw err;
}
}
+1 -15
View File
@@ -8,28 +8,17 @@ import { eq } from 'drizzle-orm';
export const onRequest = defineMiddleware(async (context, next) => {
context.locals.user = null;
const cookieHeader = context.request.headers.get('cookie');
console.log('[Middleware] Cookie header:', cookieHeader ? cookieHeader.slice(0, 50) + '...' : 'none');
const token = context.cookies.get('auth_token')?.value;
if (!token) {
console.log('[Middleware] No auth_token cookie found');
return next();
}
console.log('[Middleware] auth_token cookie found');
if (!token) return next();
try {
await verifyToken(token);
const session = await getSession(token);
if (!session) {
console.log('[Middleware] Session validation failed: no session in DB');
context.cookies.delete('auth_token', { path: '/' });
return next();
}
console.log('[Middleware] Session validation succeeded');
const userResult = await db
.select()
.from(users)
@@ -42,13 +31,10 @@ export const onRequest = defineMiddleware(async (context, next) => {
...user,
tier: (user.tier ?? 'free') as 'free' | 'pro',
};
console.log('[Middleware] User attached to locals:', { userId: user.id, name: user.name });
} else {
console.log('[Middleware] No user found for session');
context.cookies.delete('auth_token', { path: '/' });
}
} catch {
console.log('[Middleware] Session validation failed: invalid token');
context.cookies.delete('auth_token', { path: '/' });
}
+124
View File
@@ -0,0 +1,124 @@
import type { APIRoute } from "astro";
import { jsonResponse, handleCorsPreflight, createCorsResponse } from "@/lib/cors";
import { checkRateLimit, incrementGenerationCounter, getRetryAfterSeconds } from "@/lib/rate-limit";
import { generateSpell as generateOpenRouterSpell } from "@/lib/ai/openrouter";
import { generateSpell as generateKimiSpell } from "@/lib/ai/kimi";
import type { Open5eSpell } from "@/lib/ai/types";
import { searchSpells } from "@/lib/open5e/client";
import { spellParamsSchema, spellResultSchema, type SpellResult } from "@/lib/ai/types";
import { z } from "zod";
export const prerender = false;
const generateRequestSchema = spellParamsSchema.extend({
useOpen5eReference: z.boolean().optional(),
});
function getOrigin(request: Request): string | null {
return request.headers.get("origin");
}
function requireAuth(locals: App.Locals, origin: string | null): Response | null {
if (!locals.user) {
return jsonResponse({ error: "Unauthorized" }, 401, origin);
}
return null;
}
async function fetchOpen5eReference(level: number, school: string): Promise<Open5eSpell | null> {
try {
const spells = await searchSpells(school, { level: String(level) });
if (spells.length > 0) {
return spells[0] as unknown as Open5eSpell;
}
const fallback = await searchSpells("", { level: String(level) });
if (fallback.length > 0) {
return fallback[0] as unknown as Open5eSpell;
}
} catch {
return null;
}
return null;
}
export const POST: APIRoute = async ({ request, locals }) => {
const origin = getOrigin(request);
const authError = requireAuth(locals, origin);
if (authError) return authError;
const user = locals.user!;
const tier = user.tier;
const rateLimit = await checkRateLimit(user.id, tier);
if (!rateLimit.allowed) {
return createCorsResponse(
JSON.stringify({ error: "Rate limit exceeded" }),
429,
origin,
{ "Content-Type": "application/json", "Retry-After": String(getRetryAfterSeconds()) },
);
}
let body: unknown;
try {
body = await request.json();
} catch {
return jsonResponse({ error: "Invalid JSON" }, 400, origin);
}
const parsed = generateRequestSchema.safeParse(body);
if (!parsed.success) {
return jsonResponse(
{ error: "Validation failed", details: parsed.error.format() },
400,
origin,
);
}
const { name, level, school, classes, tone, useOpen5eReference } = parsed.data;
let reference: Open5eSpell | null = null;
if (useOpen5eReference) {
reference = await fetchOpen5eReference(level, school);
}
const aiClient = tier === "pro" ? generateKimiSpell : generateOpenRouterSpell;
const modelName = tier === "pro" ? "moonshot-v1-8k" : "llama-3.3-70b:free";
let spell: SpellResult;
try {
spell = await aiClient({ name, level, school, classes, tone }, reference ?? undefined);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse(
{ error: "AI generation failed", details: message },
502,
origin,
);
}
const validation = spellResultSchema.safeParse(spell);
if (!validation.success) {
return jsonResponse(
{ error: "Invalid AI response", details: validation.error.message },
502,
origin,
);
}
try {
await incrementGenerationCounter(user.id, modelName);
} catch {
// best-effort: counter increment failures should not block the response
}
const response: { spell: SpellResult; reference?: Open5eSpell } = { spell };
if (reference) response.reference = reference;
return jsonResponse(response, 200, origin);
};
export const OPTIONS: APIRoute = async ({ request }) => {
const origin = getOrigin(request);
return handleCorsPreflight(origin);
};
+1
View File
@@ -142,6 +142,7 @@ export const POST: APIRoute = async ({ request, locals }) => {
try {
await incrementGenerationCounter(user.id, modelName);
} catch {
// best-effort: counter increment failures should not block the response
}
const response: { npc: NPCResult & { id: number }; reference?: Open5eMonster } = {
+1 -1
View File
@@ -2,7 +2,7 @@ import type { APIRoute } from "astro";
import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
import { db } from "@/db/client";
import { npcs } from "@/db/schema";
import { eq, desc, sql, count } from "drizzle-orm";
import { eq, desc, count } from "drizzle-orm";
export const prerender = false;
+19 -9
View File
@@ -3,12 +3,20 @@ import { jsonResponse, handleCorsPreflight } from "@/lib/cors";
import { db } from "@/db/client";
import { translations } from "@/db/schema";
import { eq, and } from "drizzle-orm";
import { getMonster, getSpell } from "@/lib/open5e/client";
import { translateOpen5eContent } from "@/lib/ai/openrouter";
import {
getMonster,
getSpell,
getEquipmentItem,
getMagicItem,
} from "@/lib/open5e/client";
import {
translateOpen5eContent,
type Open5eContentType,
} from "@/lib/ai/openrouter";
export const prerender = false;
const ALLOWED_TYPES = new Set(["creature", "spell"]);
const ALLOWED_TYPES = new Set(["creature", "spell", "equipment", "magicitem"]);
function getOrigin(request: Request): string | null {
return request.headers.get("origin");
@@ -30,7 +38,7 @@ export const GET: APIRoute = async ({ request }) => {
if (!ALLOWED_TYPES.has(type)) {
return jsonResponse(
{ error: `Invalid type. Allowed: creature, spell` },
{ error: `Invalid type. Allowed: creature, spell, equipment, magicitem` },
400,
origin
);
@@ -59,11 +67,13 @@ export const GET: APIRoute = async ({ request }) => {
let original: Record<string, unknown>;
try {
if (type === "creature") {
const monster = await getMonster(slug);
original = monster as unknown as Record<string, unknown>;
original = (await getMonster(slug)) as unknown as Record<string, unknown>;
} else if (type === "spell") {
original = (await getSpell(slug)) as unknown as Record<string, unknown>;
} else if (type === "equipment") {
original = (await getEquipmentItem(slug)) as unknown as Record<string, unknown>;
} else {
const spell = await getSpell(slug);
original = spell as unknown as Record<string, unknown>;
original = (await getMagicItem(slug)) as unknown as Record<string, unknown>;
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
@@ -76,7 +86,7 @@ export const GET: APIRoute = async ({ request }) => {
let translated: Record<string, unknown>;
try {
translated = await translateOpen5eContent(original, type as "creature" | "spell");
translated = await translateOpen5eContent(original, type as Open5eContentType);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse(
+12 -29
View File
@@ -8,6 +8,9 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import AiKindSwitcher from "@/components/dm/AiKindSwitcher.astro";
import AiQuotaExhausted from "@/components/dm/AiQuotaExhausted.astro";
import ImportModal from "@/components/dm/ImportModal.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
@@ -16,6 +19,7 @@ let quota = null;
if (user) {
quota = await getRemainingQuota(user.id, user.tier);
}
const quotaExhausted = !!user && user.tier === "free" && !!quota && quota.remaining <= 0;
---
<DmLayout>
@@ -45,7 +49,7 @@ if (user) {
{T.initiative}
</h2>
</div>
<InitiativeTracker />
<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />
</section>
<!-- AI Generator -->
@@ -56,13 +60,7 @@ if (user) {
{T.ai}
</h2>
</div>
{Astro.locals.user ? (
<DmCard>
<div class="p-6 text-center text-[var(--text-secondary)]">
<p>{T.aiPlaceholder}</p>
</div>
</DmCard>
) : (
{!user ? (
<DmCard>
<div class="p-6 text-center">
<p class="text-[var(--text-secondary)] mb-4">{T.aiSignInCta}</p>
@@ -74,27 +72,10 @@ if (user) {
</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>
) : quotaExhausted ? (
<AiQuotaExhausted resetAt={quota!.resetAt} />
) : (
<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>
<AiKindSwitcher />
)}
</section>
</div>
@@ -123,8 +104,10 @@ if (user) {
</h2>
</div>
<DmCard>
<NotesPanel />
<NotesPanel tier={user?.tier ?? 'free'} userId={user?.id} />
</DmCard>
</section>
</div>
<ImportModal tier={user?.tier ?? 'free'} userId={user?.id} />
</DmLayout>
+12 -4
View File
@@ -6,6 +6,8 @@ import { generators } from "@/data/generators";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
export const prerender = true;
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
@@ -18,7 +20,6 @@ const webSiteSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout>
<script
@@ -99,9 +100,16 @@ export const prerender = true;
</svg>
</div>
<div>
<span class="text-sm font-semibold text-[#E87722] group-hover:text-[#F09040] transition-colors">
{T.dmDashboard}
</span>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-semibold text-[#E87722] group-hover:text-[#F09040] transition-colors">
{T.dmDashboard}
</span>
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-500/15 text-amber-300 border border-amber-500/30"
>
{T.wip}
</span>
</div>
<p class="text-sm text-zinc-400 mt-0.5">
{T.dmDashboardDesc}
</p>
+16 -10
View File
@@ -8,6 +8,9 @@ import DiceRoller from "@/components/dm/DiceRoller.astro";
import InitiativeTracker from "@/components/dm/InitiativeTracker.astro";
import Open5eReference from "@/components/dm/Open5eReference.astro";
import NotesPanel from "@/components/dm/NotesPanel.astro";
import AiKindSwitcher from "@/components/dm/AiKindSwitcher.astro";
import AiQuotaExhausted from "@/components/dm/AiQuotaExhausted.astro";
import ImportModal from "@/components/dm/ImportModal.astro";
import { dmTranslations as T } from "@/i18n/dm-translations";
import { getRemainingQuota, TIER_LIMITS } from "@/lib/rate-limit";
@@ -16,6 +19,7 @@ let quota = null;
if (user) {
quota = await getRemainingQuota(user.id, user.tier);
}
const quotaExhausted = !!user && user.tier === "free" && !!quota && quota.remaining <= 0;
---
<DmLayout>
@@ -45,7 +49,7 @@ if (user) {
{T.initiative}
</h2>
</div>
<InitiativeTracker />
<InitiativeTracker tier={user?.tier ?? 'free'} userId={user?.id} />
</section>
<!-- AI Generator -->
@@ -56,17 +60,17 @@ if (user) {
{T.ai}
</h2>
</div>
<DmCard>
{Astro.locals.user ? (
<div class="p-6 text-center text-[var(--text-secondary)]">
<p>{T.aiPlaceholder}</p>
</div>
) : (
{!user ? (
<DmCard>
<div class="p-6 text-center">
<p class="text-[var(--text-secondary)]">{T.aiSignInCta}</p>
</div>
)}
</DmCard>
</DmCard>
) : quotaExhausted ? (
<AiQuotaExhausted resetAt={quota!.resetAt} />
) : (
<AiKindSwitcher />
)}
</section>
</div>
@@ -94,8 +98,10 @@ if (user) {
</h2>
</div>
<DmCard>
<NotesPanel />
<NotesPanel tier={user?.tier ?? 'free'} userId={user?.id} />
</DmCard>
</section>
</div>
<ImportModal tier={user?.tier ?? 'free'} userId={user?.id} />
</DmLayout>
+12 -4
View File
@@ -6,6 +6,8 @@ import { generators } from "@/data/generators";
import { useT } from "@/i18n/translations";
import type { Lang } from "@/i18n/translations";
export const prerender = true;
const lang = (Astro.currentLocale as Lang) || "en";
const T = useT(lang);
@@ -18,7 +20,6 @@ const webSiteSchema = {
inLanguage: lang,
};
---
export const prerender = true;
<BaseLayout>
<script
@@ -95,9 +96,16 @@ export const prerender = true;
</svg>
</div>
<div>
<span class="text-sm font-semibold text-[#E87722] group-hover:text-[#F09040] transition-colors">
{T.dmDashboard}
</span>
<div class="flex items-center gap-2 flex-wrap">
<span class="text-sm font-semibold text-[#E87722] group-hover:text-[#F09040] transition-colors">
{T.dmDashboard}
</span>
<span
class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold uppercase tracking-wide bg-amber-500/15 text-amber-300 border border-amber-500/30"
>
{T.wip}
</span>
</div>
<p class="text-sm text-zinc-400 mt-0.5">
{T.dmDashboardDesc}
</p>
+6 -6
View File
@@ -1,9 +1,9 @@
.dm-theme {
/* Primary Accents */
--accent: #534AB7;
--accent-light: #6b5fc7;
--accent-dark: #3f3791;
--accent-hover: #6b5fc7;
/* Primary Accents — Orange for DM Dashboard */
--accent: #E87722;
--accent-light: #f08a3e;
--accent-dark: #c45f18;
--accent-hover: #f08a3e;
/* Secondary (Gold) */
--gold: #c8a84b;
@@ -42,7 +42,7 @@
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4), 0 2px 4px -2px rgba(0, 0, 0, 0.3);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5), 0 4px 6px -4px rgba(0, 0, 0, 0.3);
--shadow-glow: 0 0 20px rgba(83, 74, 183, 0.3);
--shadow-glow: 0 0 20px rgba(232, 119, 34, 0.3);
/* Focus Ring */
--focus-ring: 0 0 0 2px var(--bg-primary), 0 0 0 4px var(--accent);
+6 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- API handlers expect a full Astro APIContext; tests provide only the fields under test and spread `{} as any` for the rest. Typing the remainder would obscure the test intent. */
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockUser = {
@@ -37,11 +38,15 @@ function mockRequest(
} as unknown as Request;
}
// vi.mock factories are hoisted, so refs they read must use var (not let/const)
// to be visible at hoist time without ReferenceError.
/* eslint-disable no-var */
var mockOpenRouterNPC = vi.fn();
var mockKimiNPC = vi.fn();
var mockSearchMonsters = vi.fn();
var mockCheckRateLimit = vi.fn();
var mockIncrementCounter = vi.fn();
/* eslint-enable no-var */
vi.mock("@/lib/ai/openrouter", () => ({
generateNPC: (...args: unknown[]) => mockOpenRouterNPC(...args),
@@ -422,4 +427,4 @@ describe("AI Generate NPC API", () => {
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://randify.pro");
});
});
});
+2 -1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- API handlers expect a full Astro APIContext; tests provide only the fields under test and spread `{} as any` for the rest. Typing the remainder would obscure the test intent. */
import { describe, it, expect } from "vitest";
import {
getCorsHeaders,
@@ -114,4 +115,4 @@ describe("DM API health route", () => {
expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://dm.randify.pro");
});
});
});
+3 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- API handlers expect a full Astro APIContext; tests provide only the fields under test and spread `{} as any` for the rest. Typing the remainder would obscure the test intent. */
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockUser = {
@@ -96,7 +97,7 @@ function createMockDb() {
};
}
let mockDb = createMockDb();
const mockDb = createMockDb();
vi.mock("drizzle-orm", async (importOriginal) => {
const actual = await importOriginal<typeof import("drizzle-orm")>();
@@ -352,4 +353,4 @@ describe("AI History API", () => {
expect(body.items).toHaveLength(1);
expect(body.items[0].name).toBe("NPC 1");
});
});
});
+128
View File
@@ -0,0 +1,128 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
getLocalNotes,
getLocalInitiative,
hasLocalData,
wasAsked,
markAsked,
shouldShowImport,
} from "../src/lib/client/import";
describe("import flow helpers", () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
});
it("returns empty notes when not set", () => {
expect(getLocalNotes()).toBe("");
});
it("returns local notes content", () => {
localStorage.setItem("dm-notes", "Battle plan: flank left");
expect(getLocalNotes()).toBe("Battle plan: flank left");
});
it("returns empty initiative when not set", () => {
expect(getLocalInitiative()).toEqual([]);
});
it("parses valid initiative from sessionStorage", () => {
const data = [
{ id: "a", name: "Goblin", initiative: 12, hp: 7 },
{ id: "b", name: "Bandit", initiative: 9 },
];
sessionStorage.setItem("dm-initiative", JSON.stringify(data));
const result = getLocalInitiative();
expect(result).toHaveLength(2);
expect(result[0].name).toBe("Goblin");
expect(result[0].hp).toBe(7);
expect(result[1].initiative).toBe(9);
});
it("falls back to legacy initiative key", () => {
sessionStorage.setItem(
"it-combatants",
JSON.stringify([{ name: "Old", initiative: 5 }]),
);
const result = getLocalInitiative();
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Old");
});
it("filters out entries without a name", () => {
sessionStorage.setItem(
"dm-initiative",
JSON.stringify([{ id: "x", initiative: 1 }, { name: "Keep", id: "y" }]),
);
const result = getLocalInitiative();
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Keep");
});
it("handles malformed JSON gracefully", () => {
sessionStorage.setItem("dm-initiative", "not-json");
expect(getLocalInitiative()).toEqual([]);
});
it("hasLocalData returns false for empty storage", () => {
expect(hasLocalData()).toBe(false);
});
it("hasLocalData returns true when notes have content", () => {
localStorage.setItem("dm-notes", "hi");
expect(hasLocalData()).toBe(true);
});
it("hasLocalData ignores whitespace-only notes", () => {
localStorage.setItem("dm-notes", " \n ");
expect(hasLocalData()).toBe(false);
});
it("hasLocalData returns true when initiative has entries", () => {
sessionStorage.setItem(
"dm-initiative",
JSON.stringify([{ id: "a", name: "X" }]),
);
expect(hasLocalData()).toBe(true);
});
it("wasAsked toggles via markAsked", () => {
expect(wasAsked()).toBe(false);
markAsked();
expect(wasAsked()).toBe(true);
});
it("shouldShowImport: false when not pro", () => {
localStorage.setItem("dm-notes", "x");
expect(shouldShowImport("free", 1)).toBe(false);
});
it("shouldShowImport: false when pro but no userId", () => {
localStorage.setItem("dm-notes", "x");
expect(shouldShowImport("pro", undefined)).toBe(false);
});
it("shouldShowImport: false when no local data", () => {
expect(shouldShowImport("pro", 1)).toBe(false);
});
it("shouldShowImport: false when already asked", () => {
localStorage.setItem("dm-notes", "x");
markAsked();
expect(shouldShowImport("pro", 1)).toBe(false);
});
it("shouldShowImport: true when pro, has data, not asked", () => {
localStorage.setItem("dm-notes", "x");
expect(shouldShowImport("pro", 42)).toBe(true);
});
it("shouldShowImport: true with only initiative data", () => {
sessionStorage.setItem(
"dm-initiative",
JSON.stringify([{ id: "a", name: "Orc" }]),
);
expect(shouldShowImport("pro", 7)).toBe(true);
});
});
+3 -14
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- API handlers expect a full Astro APIContext; tests provide only the fields under test and spread `{} as any` for the rest. Typing the remainder would obscure the test intent. */
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockUser = {
@@ -12,18 +13,6 @@ const mockUser = {
createdAt: new Date("2024-01-01"),
};
const mockUser2 = {
id: 2,
vkId: null,
yandexId: null,
email: null,
name: "Other User",
avatar: null,
tier: "free" as const,
boostyVerifiedAt: null,
createdAt: new Date("2024-01-01"),
};
function mockRequest(
url: string,
origin: string,
@@ -132,7 +121,7 @@ function createMockDb() {
};
}
let mockDb = createMockDb();
const mockDb = createMockDb();
vi.mock("drizzle-orm", async (importOriginal) => {
const actual = await importOriginal<typeof import("drizzle-orm")>();
@@ -359,4 +348,4 @@ describe("Initiative API", () => {
expect(body.name).toBe("Empty Session");
expect(body.participants).toEqual([]);
});
});
});
+3 -14
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- API handlers expect a full Astro APIContext; tests provide only the fields under test and spread `{} as any` for the rest. Typing the remainder would obscure the test intent. */
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockUser = {
@@ -12,18 +13,6 @@ const mockUser = {
createdAt: new Date("2024-01-01"),
};
const mockUser2 = {
id: 2,
vkId: null,
yandexId: null,
email: null,
name: "Other User",
avatar: null,
tier: "free" as const,
boostyVerifiedAt: null,
createdAt: new Date("2024-01-01"),
};
function mockRequest(
url: string,
origin: string,
@@ -132,7 +121,7 @@ function createMockDb() {
};
}
let mockDb = createMockDb();
const mockDb = createMockDb();
vi.mock("drizzle-orm", async (importOriginal) => {
const actual = await importOriginal<typeof import("drizzle-orm")>();
@@ -340,4 +329,4 @@ describe("Notes API", () => {
expect(body.title).toBe("Title Only");
expect(body.content).toBeNull();
});
});
});
+2
View File
@@ -5,6 +5,7 @@ import type { Monster, Spell } from "../src/lib/open5e/client";
// If a listed key is missing from the interface, TypeScript will error at compile time.
type AssertKeys<T, K extends keyof T> = K;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _MonsterHasAllFields = AssertKeys<
Monster,
| "name"
@@ -34,6 +35,7 @@ type _MonsterHasAllFields = AssertKeys<
| "condition_immunities"
>;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type _SpellHasAllFields = AssertKeys<
Spell,
| "name"
+3 -2
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-explicit-any -- API handlers expect a full Astro APIContext; tests provide only the fields under test and spread `{} as any` for the rest. Typing the remainder would obscure the test intent. */
import { describe, it, expect, vi, beforeEach } from "vitest";
function mockRequest(url: string, origin: string, method = "GET"): Request {
@@ -77,7 +78,7 @@ function createMockDb() {
};
}
let mockDb = createMockDb();
const mockDb = createMockDb();
vi.mock("drizzle-orm", async (importOriginal) => {
const actual = await importOriginal<typeof import("drizzle-orm")>();
@@ -272,4 +273,4 @@ describe("Translate API", () => {
const response = await GET!({ request, locals: { user: null }, url: new URL(request.url), ...({} as any) });
expect(response.status).toBe(200);
});
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const mockPostMessage = vi.fn();
const mockAddEventListener = vi.fn();
function MockBroadcastChannel(this: unknown) {
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 {
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);
});
});