Compare commits
10
Commits
75f2dc6972
...
4f3e641458
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f3e641458 | ||
|
|
815c5c7b04 | ||
|
|
79e1dc4144 | ||
|
|
cce582df6c | ||
|
|
e6dec1eb95 | ||
|
|
4b78e9edad | ||
|
|
f2afaf60a3 | ||
|
|
2f7453e571 | ||
|
|
701398f627 | ||
|
|
09e947f430 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -19,4 +19,12 @@ export default [
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/db/migrate.mjs"],
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
import DmCard from "./DmCard.astro";
|
||||
import DmButton from "./DmButton.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
|
||||
export interface Props {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const { disabled = false } = Astro.props;
|
||||
|
||||
const selectClasses =
|
||||
"w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)] appearance-none cursor-pointer";
|
||||
|
||||
const labelClasses = "block text-sm font-medium text-[var(--text-secondary)] mb-1";
|
||||
---
|
||||
|
||||
<DmCard dataTestid="ai-npc-form-card">
|
||||
<form
|
||||
id="ai-npc-form"
|
||||
class="space-y-4"
|
||||
data-testid="ai-npc-form"
|
||||
>
|
||||
<!-- Race -->
|
||||
<div>
|
||||
<label for="npc-race" class={labelClasses}>
|
||||
{T.aiFormRace}
|
||||
</label>
|
||||
<select id="npc-race" name="race" class={selectClasses} required disabled={disabled}>
|
||||
<option value="" disabled selected>{T.aiFormSelectOption}</option>
|
||||
<option value="Human">{T.raceHuman}</option>
|
||||
<option value="Elf">{T.raceElf}</option>
|
||||
<option value="Dwarf">{T.raceDwarf}</option>
|
||||
<option value="Halfling">{T.raceHalfling}</option>
|
||||
<option value="Orc">{T.raceOrc}</option>
|
||||
<option value="Tiefling">{T.raceTiefling}</option>
|
||||
<option value="Dragonborn">{T.raceDragonborn}</option>
|
||||
<option value="Gnome">{T.raceGnome}</option>
|
||||
<option value="Half-Elf">{T.raceHalfElf}</option>
|
||||
<option value="Half-Orc">{T.raceHalfOrc}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Role -->
|
||||
<div>
|
||||
<label for="npc-role" class={labelClasses}>
|
||||
{T.aiFormRole}
|
||||
</label>
|
||||
<select id="npc-role" name="role" class={selectClasses} required disabled={disabled}>
|
||||
<option value="" disabled selected>{T.aiFormSelectOption}</option>
|
||||
<option value="Warrior">{T.roleWarrior}</option>
|
||||
<option value="Mage">{T.roleMage}</option>
|
||||
<option value="Rogue">{T.roleRogue}</option>
|
||||
<option value="Cleric">{T.roleCleric}</option>
|
||||
<option value="Ranger">{T.roleRanger}</option>
|
||||
<option value="Paladin">{T.rolePaladin}</option>
|
||||
<option value="Bard">{T.roleBard}</option>
|
||||
<option value="Barbarian">{T.roleBarbarian}</option>
|
||||
<option value="Druid">{T.roleDruid}</option>
|
||||
<option value="Warlock">{T.roleWarlock}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Level -->
|
||||
<div>
|
||||
<label for="npc-level" class={labelClasses}>
|
||||
{T.aiFormLevel}
|
||||
</label>
|
||||
<input
|
||||
id="npc-level"
|
||||
name="level"
|
||||
type="number"
|
||||
min="1"
|
||||
max="20"
|
||||
value="1"
|
||||
class="w-full bg-[var(--bg-input)] border border-[var(--border-color)] rounded-lg px-3 py-2 text-[var(--text-primary)] text-base placeholder:text-[var(--text-secondary)] hover:border-[var(--text-secondary)] focus:outline-none focus:border-[var(--gold)] focus:ring-1 focus:ring-[var(--gold)] focus-visible:ring-2 focus-visible:ring-[var(--gold)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--bg-primary)] transition-all duration-[var(--transition-base)]"
|
||||
required
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Tone -->
|
||||
<div>
|
||||
<label for="npc-tone" class={labelClasses}>
|
||||
{T.aiFormTone}
|
||||
</label>
|
||||
<select id="npc-tone" name="tone" class={selectClasses} required disabled={disabled}>
|
||||
<option value="" disabled selected>{T.aiFormSelectOption}</option>
|
||||
<option value="Serious">{T.toneSerious}</option>
|
||||
<option value="Humorous">{T.toneHumorous}</option>
|
||||
<option value="Dark">{T.toneDark}</option>
|
||||
<option value="Heroic">{T.toneHeroic}</option>
|
||||
<option value="Mysterious">{T.toneMysterious}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Open5e Reference Toggle -->
|
||||
<div class="flex items-center gap-3">
|
||||
<input
|
||||
id="npc-use-reference"
|
||||
name="useOpen5eReference"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-[var(--border-color)] bg-[var(--bg-input)] text-[var(--accent)] accent-[var(--accent)] focus:ring-[var(--gold)] focus:ring-2 focus:ring-offset-2 focus:ring-offset-[var(--bg-primary)] cursor-pointer"
|
||||
disabled={disabled}
|
||||
/>
|
||||
<label for="npc-use-reference" class="text-sm text-[var(--text-secondary)] cursor-pointer">
|
||||
{T.aiFormUseReference}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="pt-2">
|
||||
<DmButton
|
||||
id="ai-npc-submit"
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="md"
|
||||
class="w-full"
|
||||
disabled={disabled}
|
||||
dataTestid="ai-npc-submit"
|
||||
>
|
||||
{T.aiFormSubmit}
|
||||
</DmButton>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div
|
||||
id="ai-npc-status"
|
||||
class="text-sm text-center min-h-[1.5rem]"
|
||||
aria-live="polite"
|
||||
data-testid="ai-npc-status"
|
||||
></div>
|
||||
</form>
|
||||
</DmCard>
|
||||
|
||||
<script is:inline define:vars={{ generatingLabel: T.npcGenerating, submitLabel: T.npcGenerate, rateLimitTemplate: T.npcErrorRateLimit, genericError: T.npcErrorGeneric }}>
|
||||
(function () {
|
||||
const form = document.getElementById("ai-npc-form") as HTMLFormElement | null;
|
||||
const submitBtn = document.getElementById("ai-npc-submit") as HTMLButtonElement | null;
|
||||
const statusEl = document.getElementById("ai-npc-status");
|
||||
|
||||
if (!form || !submitBtn || !statusEl) return;
|
||||
|
||||
let isSubmitting = false;
|
||||
|
||||
function setStatus(message: string, type: "error" | "info" = "info") {
|
||||
statusEl.textContent = message;
|
||||
statusEl.className =
|
||||
"text-sm text-center min-h-[1.5rem] " +
|
||||
(type === "error" ? "text-[var(--danger)]" : "text-[var(--text-secondary)]");
|
||||
}
|
||||
|
||||
function setLoading(loading: boolean) {
|
||||
isSubmitting = loading;
|
||||
submitBtn.disabled = loading;
|
||||
submitBtn.textContent = loading ? generatingLabel : submitLabel;
|
||||
submitBtn.setAttribute("aria-busy", loading ? "true" : "false");
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (isSubmitting) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const body = {
|
||||
race: String(formData.get("race")),
|
||||
role: String(formData.get("role")),
|
||||
level: parseInt(String(formData.get("level")), 10),
|
||||
tone: String(formData.get("tone")),
|
||||
useOpen5eReference: formData.get("useOpen5eReference") === "on",
|
||||
};
|
||||
|
||||
setLoading(true);
|
||||
setStatus("");
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/dm/ai/generate", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (response.status === 429) {
|
||||
const retryAfter = response.headers.get("Retry-After");
|
||||
const minutes = retryAfter ? Math.ceil(parseInt(retryAfter, 10) / 60) : 60;
|
||||
setStatus(rateLimitTemplate.replace("{minutes}", String(minutes)), "error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
setStatus(genericError, "error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
if (!result.npc) {
|
||||
setStatus(genericError, "error");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("");
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("npc-generated", {
|
||||
detail: result.npc,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
} catch {
|
||||
setStatus(genericError, "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,172 @@
|
||||
---
|
||||
import DmCard from "./DmCard.astro";
|
||||
import DmButton from "./DmButton.astro";
|
||||
import { dmTranslations as T } from "@/i18n/dm-translations";
|
||||
import type { NPCResult } from "@/lib/ai/types";
|
||||
|
||||
export interface Props {
|
||||
npc: NPCResult;
|
||||
onRegenerate?: () => void;
|
||||
}
|
||||
|
||||
const { npc, onRegenerate } = Astro.props;
|
||||
---
|
||||
|
||||
<DmCard padding="lg" dataTestid="ai-npc-result">
|
||||
<div class="flex items-start justify-between gap-4 mb-5">
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-xl font-bold text-[var(--text-primary)] truncate">
|
||||
{npc.name}
|
||||
</h3>
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-1">
|
||||
{npc.race} · {npc.role} · {T.npcLevel} {npc.level} · {T.npcCr} {npc.cr}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<DmButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
id="npc-copy-btn"
|
||||
dataTestid="npc-copy-btn"
|
||||
>
|
||||
{T.npcCopyJson}
|
||||
</DmButton>
|
||||
{
|
||||
onRegenerate && (
|
||||
<DmButton
|
||||
variant="primary"
|
||||
size="sm"
|
||||
id="npc-regen-btn"
|
||||
dataTestid="npc-regen-btn"
|
||||
>
|
||||
{T.npcRegenerate}
|
||||
</DmButton>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-5">
|
||||
<div
|
||||
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
|
||||
data-testid="npc-stat-hp"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
|
||||
{T.npcHp}
|
||||
</div>
|
||||
<div class="text-lg font-bold text-[var(--accent)]">{npc.hp}</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
|
||||
data-testid="npc-stat-ac"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
|
||||
{T.npcAc}
|
||||
</div>
|
||||
<div class="text-lg font-bold text-[var(--accent)]">{npc.ac}</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
|
||||
data-testid="npc-stat-speed"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
|
||||
{T.npcSpeed}
|
||||
</div>
|
||||
<div class="text-lg font-bold text-[var(--accent)]">{npc.speed}</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-lg bg-[var(--bg-secondary)] border border-[var(--border-color)] p-3 text-center"
|
||||
data-testid="npc-stat-cr"
|
||||
>
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider">
|
||||
{T.npcCr}
|
||||
</div>
|
||||
<div class="text-lg font-bold text-[var(--accent)]">{npc.cr}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-5">
|
||||
<div data-testid="npc-appearance">
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
|
||||
{T.npcAppearance}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{npc.appearance}
|
||||
</p>
|
||||
</div>
|
||||
<div data-testid="npc-trait">
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
|
||||
{T.npcTrait}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{npc.trait}
|
||||
</p>
|
||||
</div>
|
||||
<div data-testid="npc-motivation">
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
|
||||
{T.npcMotivation}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{npc.motivation}
|
||||
</p>
|
||||
</div>
|
||||
<div data-testid="npc-secret">
|
||||
<div class="text-xs text-[var(--text-muted)] uppercase tracking-wider mb-0.5">
|
||||
{T.npcSecret}
|
||||
</div>
|
||||
<p class="text-sm text-[var(--text-primary)] leading-relaxed">
|
||||
{npc.secret}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="border-l-4 border-[var(--accent)] pl-4 py-2 italic text-[var(--text-cream)] bg-[var(--bg-secondary)]/50 rounded-r-lg mb-5"
|
||||
data-testid="npc-history"
|
||||
>
|
||||
<p class="text-sm leading-relaxed">{npc.history}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<span
|
||||
class="px-3 py-1 text-xs font-medium rounded-full bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20"
|
||||
>
|
||||
{npc.race}
|
||||
</span>
|
||||
<span
|
||||
class="px-3 py-1 text-xs font-medium rounded-full bg-[var(--accent)]/10 text-[var(--accent)] border border-[var(--accent)]/20"
|
||||
>
|
||||
{npc.role}
|
||||
</span>
|
||||
</div>
|
||||
</DmCard>
|
||||
|
||||
<script is:inline define:vars={{ npcJson: JSON.stringify(npc), copiedLabel: T.npcCopied, hasRegenerate: !!onRegenerate }}>
|
||||
(function () {
|
||||
const copyBtn = document.getElementById("npc-copy-btn");
|
||||
const regenBtn = document.getElementById("npc-regen-btn");
|
||||
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(npcJson);
|
||||
const originalText = copyBtn.textContent || "";
|
||||
copyBtn.textContent = copiedLabel;
|
||||
setTimeout(() => {
|
||||
copyBtn.textContent = originalText;
|
||||
}, 1500);
|
||||
} catch {
|
||||
/* ignore clipboard errors */
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (regenBtn && hasRegenerate) {
|
||||
regenBtn.addEventListener("click", () => {
|
||||
regenBtn.dispatchEvent(
|
||||
new CustomEvent("npc-regenerate", { bubbles: true })
|
||||
);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,335 @@
|
||||
---
|
||||
export interface Props {
|
||||
tier: 'free' | 'pro';
|
||||
userId?: number;
|
||||
}
|
||||
|
||||
import DmCard from './DmCard.astro';
|
||||
import { dmTranslations as T } from '@/i18n/dm-translations';
|
||||
|
||||
const { tier, userId } = Astro.props;
|
||||
---
|
||||
|
||||
<div
|
||||
class="generation-history"
|
||||
data-tier={tier}
|
||||
data-user-id={userId}
|
||||
data-testid="generation-history"
|
||||
>
|
||||
<DmCard padding="md">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-sm font-semibold text-[var(--text-secondary)] uppercase tracking-wide">
|
||||
{T.historyTitle}
|
||||
</h3>
|
||||
<span
|
||||
id="history-count"
|
||||
class="text-xs text-[var(--text-muted)]"
|
||||
data-testid="history-count"
|
||||
>
|
||||
0
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="history-loading" class="hidden py-4 text-center text-[var(--text-muted)] text-sm">
|
||||
{T.loading}
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="history-empty-pro"
|
||||
class="hidden flex flex-col items-center justify-center py-6 text-[var(--text-muted)]"
|
||||
data-testid="history-empty-pro"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mb-2 opacity-40"
|
||||
>
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<polyline points="14 2 14 8 20 8" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
</svg>
|
||||
<span class="text-sm">{T.historyEmptyPro}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
id="history-empty-free"
|
||||
class="hidden flex flex-col items-center justify-center py-6 px-4 rounded-lg border-2 border-dashed border-[var(--border-color)] text-center"
|
||||
data-testid="history-empty-free"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="28"
|
||||
height="28"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="mb-2 text-[var(--accent)] opacity-60"
|
||||
>
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z" />
|
||||
<path d="M2 17l10 5 10-5" />
|
||||
<path d="M2 12l10 5 10-5" />
|
||||
</svg>
|
||||
<p class="text-sm text-[var(--text-secondary)] mb-2">{T.historyEmptyFree}</p>
|
||||
<a
|
||||
href="/dm/upgrade"
|
||||
class="inline-flex items-center justify-center rounded-lg px-4 py-1.5 text-sm font-medium bg-[var(--accent)] text-white hover:bg-[var(--accent-hover)] transition-colors"
|
||||
>
|
||||
{T.historyUpgradeCta}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<ul
|
||||
id="history-list"
|
||||
class="space-y-1 max-h-60 overflow-y-auto hidden"
|
||||
role="list"
|
||||
aria-label={T.historyTitle}
|
||||
data-testid="history-list"
|
||||
>
|
||||
</ul>
|
||||
</DmCard>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
import type { NPCResult } from '@/lib/ai/types';
|
||||
|
||||
interface HistoryItem {
|
||||
id: number;
|
||||
name: string;
|
||||
race: string | null;
|
||||
role: string | null;
|
||||
level: number | null;
|
||||
content: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const FREE_STORAGE_KEY = 'dm-ai-history-free';
|
||||
const MAX_HISTORY = 20;
|
||||
|
||||
function loadFreeHistory(): HistoryItem[] {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(FREE_STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
return JSON.parse(raw) as HistoryItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveFreeHistory(items: HistoryItem[]) {
|
||||
try {
|
||||
sessionStorage.setItem(FREE_STORAGE_KEY, JSON.stringify(items.slice(0, MAX_HISTORY)));
|
||||
} catch {
|
||||
// sessionStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ru-RU', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
function renderHistory(container: HTMLElement, items: HistoryItem[], activeId: number | null) {
|
||||
const list = container.querySelector<HTMLUListElement>('#history-list');
|
||||
const emptyPro = container.querySelector<HTMLDivElement>('#history-empty-pro');
|
||||
const emptyFree = container.querySelector<HTMLDivElement>('#history-empty-free');
|
||||
const countEl = container.querySelector<HTMLSpanElement>('#history-count');
|
||||
|
||||
if (!list || !emptyPro || !emptyFree || !countEl) return;
|
||||
|
||||
countEl.textContent = String(items.length);
|
||||
|
||||
if (items.length === 0) {
|
||||
list.classList.add('hidden');
|
||||
list.innerHTML = '';
|
||||
|
||||
const tier = container.dataset.tier;
|
||||
if (tier === 'pro') {
|
||||
emptyPro.classList.remove('hidden');
|
||||
emptyFree.classList.add('hidden');
|
||||
} else {
|
||||
emptyPro.classList.add('hidden');
|
||||
emptyFree.classList.remove('hidden');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
emptyPro.classList.add('hidden');
|
||||
emptyFree.classList.add('hidden');
|
||||
list.classList.remove('hidden');
|
||||
|
||||
list.innerHTML = items
|
||||
.map((item) => {
|
||||
const isActive = item.id === activeId;
|
||||
const raceRole = [item.race, item.role].filter(Boolean).join(' · ');
|
||||
const levelText = item.level ? `Ур. ${item.level}` : '';
|
||||
const meta = [raceRole, levelText].filter(Boolean).join(' · ');
|
||||
const dateText = formatDate(item.createdAt);
|
||||
const timeText = formatTime(item.createdAt);
|
||||
|
||||
return `
|
||||
<li
|
||||
data-history-id="${item.id}"
|
||||
class="group cursor-pointer rounded-lg px-3 py-2.5 text-sm transition-all duration-[var(--transition-base)] border ${isActive
|
||||
? 'bg-[var(--accent)]/10 border-[var(--accent)]/40'
|
||||
: 'bg-[var(--bg-secondary)] border-transparent hover:bg-[var(--bg-secondary)] hover:border-[var(--border-color)]'
|
||||
}"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
aria-pressed="${isActive}"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-medium ${isActive ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'} truncate pr-2">
|
||||
${escapeHtml(item.name)}
|
||||
</span>
|
||||
<span class="text-xs text-[var(--text-muted)] shrink-0">
|
||||
${escapeHtml(dateText)} ${escapeHtml(timeText)}
|
||||
</span>
|
||||
</div>
|
||||
${meta ? `<div class="text-xs text-[var(--text-secondary)] mt-0.5 truncate">${escapeHtml(meta)}</div>` : ''}
|
||||
</li>
|
||||
`;
|
||||
})
|
||||
.join('');
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>('li[data-history-id]').forEach((li) => {
|
||||
li.addEventListener('click', () => {
|
||||
const id = Number(li.dataset.historyId);
|
||||
selectItem(container, items, id);
|
||||
});
|
||||
li.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const id = Number(li.dataset.historyId);
|
||||
selectItem(container, items, id);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function selectItem(container: HTMLElement, items: HistoryItem[], id: number) {
|
||||
const item = items.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
renderHistory(container, items, id);
|
||||
|
||||
const npcData: NPCResult & { id: number } = {
|
||||
...(item.content as unknown as NPCResult),
|
||||
id: item.id,
|
||||
};
|
||||
|
||||
container.dispatchEvent(
|
||||
new CustomEvent('npc-selected', {
|
||||
detail: npcData,
|
||||
bubbles: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchProHistory(): 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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('Обновлено');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = "Ошибка перевода";
|
||||
|
||||
@@ -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();
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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: '/' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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 } = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user