From 40cb03cb0b9e288290e5e488df0375afcb750b45 Mon Sep 17 00:00:00 2001 From: "s0wlz (Matthias Puchstein)" Date: Mon, 9 Mar 2026 16:45:29 +0100 Subject: [PATCH] let claude create the monorepo layout --- .gitignore | 25 + .npmrc | 1 + CLAUDE.md | 247 ++ Cargo.toml | 3 + Symbiotes Documentation.md | 287 ++ backend/Cargo.toml | 18 + backend/src/lib.rs | 1 + backend/src/main.rs | 43 + campaign_manager_design_v7.md | 2929 +++++++++++++++++ package.json | 14 + pnpm-lock.yaml | 1013 ++++++ pnpm-workspace.yaml | 4 + rulesets/package.json | 7 + rulesets/src/dsa5e/index.ts | 62 + rulesets/src/generic/index.ts | 34 + rulesets/src/index.ts | 21 + rulesets/src/types.ts | 38 + rulesets/tsconfig.json | 11 + symbiote/index.html | 12 + symbiote/package.json | 20 + symbiote/public/manifest.json | 11 + symbiote/src/App.svelte | 24 + symbiote/src/lib/api.ts | 76 + symbiote/src/lib/store.ts | 56 + symbiote/src/lib/ts-api.d.ts | 41 + symbiote/src/main.ts | 11 + symbiote/src/views/CharacterSheet.svelte | 8 + symbiote/src/views/GroupDetail.svelte | 8 + symbiote/src/views/GroupList.svelte | 8 + symbiote/src/views/Login.svelte | 9 + symbiote/src/views/RollHistory.svelte | 8 + symbiote/tsconfig.json | 13 + symbiote/vite.config.ts | 11 + web/build/404.html | 38 + web/build/_app/env.js | 1 + web/build/_app/immutable/chunks/Bzak7iHL.js | 1 + web/build/_app/immutable/chunks/C5YqYP7P.js | 1 + web/build/_app/immutable/chunks/CAYgxOZ1.js | 1 + web/build/_app/immutable/chunks/CGowzvwH.js | 1 + web/build/_app/immutable/chunks/CUCwB180.js | 1 + web/build/_app/immutable/chunks/DNqN6DmX.js | 1 + web/build/_app/immutable/chunks/Db9w--lA.js | 2 + web/build/_app/immutable/chunks/YzYuob9f.js | 1 + web/build/_app/immutable/chunks/eiK12uJk.js | 1 + web/build/_app/immutable/chunks/pTMRHjpX.js | 1 + .../_app/immutable/entry/app.H3SWXino.js | 2 + .../_app/immutable/entry/start.Pj34kLt-.js | 1 + web/build/_app/immutable/nodes/0.D7-FTC_1.js | 1 + web/build/_app/immutable/nodes/1.BZR9od8C.js | 1 + web/build/_app/immutable/nodes/2.Zg4cVoEY.js | 1 + web/build/_app/immutable/nodes/3.BjNotmmr.js | 1 + web/build/_app/immutable/nodes/4.D0WiVz9B.js | 1 + web/build/_app/immutable/nodes/5.B-zruxZU.js | 1 + web/build/_app/immutable/nodes/6.Cniq5yLG.js | 1 + web/build/_app/immutable/nodes/7.BjdHyPf8.js | 1 + web/build/_app/immutable/nodes/8.Chqc1uyl.js | 1 + web/build/_app/version.json | 1 + web/package.json | 22 + web/src/app.html | 12 + web/src/lib/api.ts | 79 + web/src/routes/+layout.svelte | 27 + web/src/routes/+page.svelte | 6 + web/src/routes/characters/+page.svelte | 6 + web/src/routes/characters/[id]/+page.svelte | 8 + web/src/routes/groups/+page.svelte | 6 + web/src/routes/groups/[id]/+page.svelte | 8 + .../routes/groups/[id]/settings/+page.svelte | 8 + web/src/routes/login/+page.svelte | 7 + web/svelte.config.js | 8 + web/tsconfig.json | 6 + web/vite.config.ts | 6 + 71 files changed, 5346 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 CLAUDE.md create mode 100644 Cargo.toml create mode 100644 Symbiotes Documentation.md create mode 100644 backend/Cargo.toml create mode 100644 backend/src/lib.rs create mode 100644 backend/src/main.rs create mode 100644 campaign_manager_design_v7.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 rulesets/package.json create mode 100644 rulesets/src/dsa5e/index.ts create mode 100644 rulesets/src/generic/index.ts create mode 100644 rulesets/src/index.ts create mode 100644 rulesets/src/types.ts create mode 100644 rulesets/tsconfig.json create mode 100644 symbiote/index.html create mode 100644 symbiote/package.json create mode 100644 symbiote/public/manifest.json create mode 100644 symbiote/src/App.svelte create mode 100644 symbiote/src/lib/api.ts create mode 100644 symbiote/src/lib/store.ts create mode 100644 symbiote/src/lib/ts-api.d.ts create mode 100644 symbiote/src/main.ts create mode 100644 symbiote/src/views/CharacterSheet.svelte create mode 100644 symbiote/src/views/GroupDetail.svelte create mode 100644 symbiote/src/views/GroupList.svelte create mode 100644 symbiote/src/views/Login.svelte create mode 100644 symbiote/src/views/RollHistory.svelte create mode 100644 symbiote/tsconfig.json create mode 100644 symbiote/vite.config.ts create mode 100644 web/build/404.html create mode 100644 web/build/_app/env.js create mode 100644 web/build/_app/immutable/chunks/Bzak7iHL.js create mode 100644 web/build/_app/immutable/chunks/C5YqYP7P.js create mode 100644 web/build/_app/immutable/chunks/CAYgxOZ1.js create mode 100644 web/build/_app/immutable/chunks/CGowzvwH.js create mode 100644 web/build/_app/immutable/chunks/CUCwB180.js create mode 100644 web/build/_app/immutable/chunks/DNqN6DmX.js create mode 100644 web/build/_app/immutable/chunks/Db9w--lA.js create mode 100644 web/build/_app/immutable/chunks/YzYuob9f.js create mode 100644 web/build/_app/immutable/chunks/eiK12uJk.js create mode 100644 web/build/_app/immutable/chunks/pTMRHjpX.js create mode 100644 web/build/_app/immutable/entry/app.H3SWXino.js create mode 100644 web/build/_app/immutable/entry/start.Pj34kLt-.js create mode 100644 web/build/_app/immutable/nodes/0.D7-FTC_1.js create mode 100644 web/build/_app/immutable/nodes/1.BZR9od8C.js create mode 100644 web/build/_app/immutable/nodes/2.Zg4cVoEY.js create mode 100644 web/build/_app/immutable/nodes/3.BjNotmmr.js create mode 100644 web/build/_app/immutable/nodes/4.D0WiVz9B.js create mode 100644 web/build/_app/immutable/nodes/5.B-zruxZU.js create mode 100644 web/build/_app/immutable/nodes/6.Cniq5yLG.js create mode 100644 web/build/_app/immutable/nodes/7.BjdHyPf8.js create mode 100644 web/build/_app/immutable/nodes/8.Chqc1uyl.js create mode 100644 web/build/_app/version.json create mode 100644 web/package.json create mode 100644 web/src/app.html create mode 100644 web/src/lib/api.ts create mode 100644 web/src/routes/+layout.svelte create mode 100644 web/src/routes/+page.svelte create mode 100644 web/src/routes/characters/+page.svelte create mode 100644 web/src/routes/characters/[id]/+page.svelte create mode 100644 web/src/routes/groups/+page.svelte create mode 100644 web/src/routes/groups/[id]/+page.svelte create mode 100644 web/src/routes/groups/[id]/settings/+page.svelte create mode 100644 web/src/routes/login/+page.svelte create mode 100644 web/svelte.config.js create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1c963ab --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Node +node_modules/ +dist/ +.svelte-kit/ +.env +.env.* +!.env.example +*.pem + +# pnpm +pnpm-debug.log* + +# Rust +target/ +Cargo.lock + +# OS +.DS_Store +Thumbs.db + +# Editor +.vscode/settings.json +.idea/ +*.swp +*.swo diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..ebe23cf --- /dev/null +++ b/.npmrc @@ -0,0 +1 @@ +approve-builds=true diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..355fc9a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,247 @@ +# Campaign Manager — CLAUDE.md + +This project is in the **architecture/design phase**. No implementation code exists yet. The source of truth is `campaign_manager_design_v7.md`. `Symbiotes Documentation.md` is the TaleSpire platform API reference. + +--- + +## What We're Building + +A TaleSpire **Campaign Manager Symbiote** — a mod embedded inside TaleSpire (Chromium-based) that provides authenticated multi-group campaign management with ruleset-agnostic character sheets and native dice integration. + +Three clients, one backend: +1. **TaleSpire Symbiote** — primary interface, runs inside the game +2. **Companion Web App** — for out-of-game character/group management +3. **Backend API** — shared by both clients + +--- + +## Technology Stack + +| Layer | Technology | Notes | +|---|---|---| +| Symbiote UI | Svelte + Vite (no Kit) | Plain Svelte for minimal bundle size; TaleSpire runs locally and the Symbiote shares resources with the game. Programmatic view-switching via a `$currentView` store — no router. | +| Web App | SvelteKit | Full SvelteKit with `adapter-static` for CDN deploy; SvelteKit's router and SSR are appropriate here since it runs in the user's own browser. Shares ruleset plugin code with the Symbiote. | +| Backend | Rust + Axum + Tokio | Async, memory-safe, high-throughput | +| DB queries | sqlx | Compile-time checked SQL, no ORM | +| Database | PostgreSQL 16 | JSONB for sheet_data; strong relational integrity | +| Auth | JWT + argon2id | argon2id preferred over bcrypt for new code | +| Object Storage | S3-compatible (Cloudflare R2) | Portrait images and .cmchar exports; keeps k8s pods stateless | +| Hosting | Kubernetes (no PVC) | Stateless pods; all persistence in Postgres + S3 | + +--- + +## Architecture + +### Three-Layer System + +``` +TaleSpire Symbiote (Chromium) ←→ Backend (Rust/Axum) ←→ PostgreSQL +Companion Web App ←→ (same API) +``` + +- Symbiote communicates over HTTPS; upgrades to WebSocket for live session features +- Web app and Symbiote share the same `/auth/*` endpoints and user accounts — no separate auth + +### Symbiote Constraints (from TaleSpire API) + +- Single-page application — no real page navigation; views are swapped programmatically to preserve the injected TS API +- **No SvelteKit for the Symbiote** — the Symbiote runs inside TaleSpire's local Chromium process and shares system resources with the game. SvelteKit's router/hydration bundle is unnecessary overhead. Use plain Svelte + Vite: one `main.ts` entry point, a `$currentView` store drives which component is rendered. +- All persistence via `TS.storage` (not `localStorage`, which is unreliable across restarts) +- Must suppress API calls during board transitions (client leave/join events) +- `manifest.json` extras: `colorStyles`, `fonts`, `diceFinder` + +### Views (Symbiote) + +`Login/Register` → `Group List` → `Group Detail` → `Character Sheet` ↔ `Roll History panel` + +### Web App Routes + +| Route | Access | +|---|---| +| `/groups` | All members | +| `/groups/:id` | Members | +| `/groups/:id/settings` | DM only | +| `/characters` | Owner | +| `/characters/:id` | Owner | + +--- + +## Data Model + +### Key Schema Decisions + +- `sheet_data` is **opaque JSONB** — the backend never reads it; only ruleset plugins do. Adding a new ruleset requires **zero DB migrations**. +- Characters are **portable**: one character can belong to multiple groups via `group_characters`. +- Exactly **one DM per group** (`groups.dm_user_id` + `group_members.role`). +- `email_verified = true` is required before joining or creating groups. + +### Core Tables + +``` +users id (UUID PK), email, password_hash, display_name, email_verified, created_at +groups id (UUID PK), name, dm_user_id (FK), ruleset_id, description, created_at +group_members group_id + user_id (composite PK), role ENUM(player, dm), joined_at +characters id (UUID PK), owner_user_id (FK), ruleset_id, name, sheet_data (JSONB), + portrait_url, created_at, updated_at +group_characters group_id + character_id (composite PK), user_id (FK), is_active, assigned_at +``` + +--- + +## REST API + +Base URL: `https://api.campaign-manager.example.com/v1` +All endpoints require `Authorization: Bearer ` unless marked public. + +### Auth + +| Method + Path | Auth | Description | +|---|---|---| +| `POST /auth/register` | Public | Create account | +| `POST /auth/login` | Public | Returns JWT pair | +| `POST /auth/refresh` | Refresh token | Rotate refresh token | +| `POST /auth/logout` | Bearer | Revoke refresh token | + +### Groups + +`GET /groups` · `POST /groups` · `GET /groups/:id` · `PATCH /groups/:id` · `DELETE /groups/:id` +`POST /groups/:id/invite` · `POST /groups/:id/join/:token` · `DELETE /groups/:id/members/:userId` + +### Characters + +`GET /characters` · `POST /characters` · `GET /characters/:id` +`PUT /characters/:id` · `PATCH /characters/:id` · `DELETE /characters/:id` +`POST /groups/:id/characters` · `DELETE /groups/:gid/characters/:cid` · `GET /groups/:id/characters` +`GET /characters/:id/history` (last 10 versions of sheet_data) + +### Dice / Sessions + +`POST /groups/:id/rolls` · `GET /groups/:id/rolls` + +### WebSocket + +One room per group ID, authenticated with the same JWT. Used for HP updates and roll history push. + +--- + +## Ruleset Plugin Interface + +Game logic is fully isolated behind a `Ruleset` interface. The core app never touches system-specific rules. + +```typescript +interface Ruleset { + id: string // e.g. 'dsa5e' + name: string + version: string // semver + defaultSheet(): SheetData + validate(sheet: SheetData): ValidationResult + getStatBlocks(sheet: SheetData): StatBlock[] + getDiceActions(sheet: SheetData): DiceAction[] + renderSheet(sheet: SheetData): VNode +} + +interface DiceAction { + label: string // 'Attack Roll', 'Perception Check', etc. + diceString: string // TaleSpire format: '1d20+5', '2d6' + category: string // 'attack' | 'skill' | 'save' | 'damage' | 'custom' +} +``` + +Plugins live in a `rulesets/` directory. Adding one = dropping in a file + registering its id. No DB changes. + +**Bundled rulesets (v1):** +- `dsa5e` — Das Schwarze Auge 5e (The Dark Eye): attributes, derived values, talents, combat techniques, special abilities, spells/liturgies +- `generic` — Minimal fallback: name, HP, arbitrary key-value pairs + +--- + +## Key Conventions + +### Auth Flow + +- **Symbiote**: tokens stored in `TS.storage`. On boot, check stored tokens; silently refresh via `POST /auth/refresh` if access token is expired. On any 401, attempt silent refresh before surfacing an error. +- **Web App**: access token in memory only; refresh token in `HttpOnly; Secure; SameSite=Strict` cookie. On hard refresh, silently call `POST /auth/refresh` via cookie before rendering protected routes. +- JWT access tokens: 15-minute lifetime. Refresh tokens: 30-day lifetime, **single-use with rotation**. +- Rate limit on auth endpoints: 10 req/min per IP. + +### Sync Strategy + +- **`TS.sync`** — lightweight broadcast to all clients on the same board running the same Symbiote. Use for transient state (e.g. initiative announcements). No backend needed. +- **WebSocket room (group-keyed)** — for persistent data (HP changes, saved rolls). Backend pushes updates to other connected clients. +- **Conflict resolution**: last-write-wins for most fields. HP uses **delta updates** (`+5 HP` not `set HP to 45`) to reduce conflicts during combat. + +### Character Sheet Saves (Web App) + +- Optimistic UI: changes applied locally immediately; rollback with toast on failure. +- Auto-save draft to backend every 30 seconds. + +### Dice Integration + +```javascript +// Programmatic roll from character sheet button +await TS.dice.putDiceInHand(action.diceString); + +// Listen for resolved results and log to backend +TS.dice.onDiceResult.addListener(async (result) => { + await api.post(`/groups/${currentGroup.id}/rolls`, { ... }); +}); +``` + +`diceFinder` extra automatically makes dice-notation text clickable on any page loaded in the Symbiote — no extra code. + +### Character Import (.cmchar format) + +```json +{ + "cm_version": "1.0", + "ruleset_id": "dsa5e", + "name": "Character Name", + "sheet_data": { } +} +``` + +Importing runs `ruleset.validate(sheet_data)` before creating any record. Field-level errors are surfaced in the UI. + +### Portrait Upload + +Client validates MIME type and size (max 2 MB) before upload. Backend issues a presigned S3 URL; client uploads directly to object storage. Backend stores only the resulting URL. k8s pods remain stateless. + +### Styling + +Use TaleSpire CSS variables (`--ts-background-primary`, `--ts-color-primary`, etc.) via the `colorStyles` extra. Use `OptimusPrinceps` font (via `fonts` extra) for headings. Use `ts-icon-*` classes for action buttons. + +--- + +## Project Structure + +``` +campaign-manager/ + symbiote/ # Plain Svelte + Vite — TaleSpire Symbiote (lean bundle) + vite.config.ts + src/ + main.ts # mounts , waits for TS.hasInitialized + App.svelte # $currentView store drives view switching + views/ # Login, GroupList, GroupDetail, CharacterSheet, RollHistory + lib/ + api.ts # shared fetch client (auto-refresh on 401) + store.ts # auth + session state + dist/ # Symbiote file root (index.html + manifest.json go here) + + web/ # SvelteKit — Companion web app + svelte.config.js # adapter-static for CDN deploy + src/ + routes/ + lib/ + + backend/ # Rust + Axum + rulesets/ # Shared ruleset plugins (plain TS — imported by both frontends) +``` + +--- + +## Out of Scope (v1) + +- Board state editing (tiles, minis) — TaleSpire API doesn't expose this +- Real-time collaborative editing of the same sheet simultaneously +- Mobile app +- PDF / D&D Beyond imports diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..d1e49e3 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["backend"] +resolver = "2" diff --git a/Symbiotes Documentation.md b/Symbiotes Documentation.md new file mode 100644 index 0000000..69672d9 --- /dev/null +++ b/Symbiotes Documentation.md @@ -0,0 +1,287 @@ +# Symbiotes Documentation + +1. [What are Symbiotes?](#symbiotes-intro) +2. [Getting Started](#getting-started) + - [Installing a Symbiote](#installing) + - [Development Workflow](#dev-workflow) +3. [Manifest Docs](#manifest-docs) + - [Manifest Parser](#manifest-parser) +4. [API Docs](#api-docs) + - [API reverse domain name](#reverse-domain-name) + - [Rate limiting](#rate-limiting) + - [Permissions](#permissions) + - [Injection](#injection) + - [Symbiote lifetime](#symbiote-lifetime) +5. [Extras](#extras-docs) + - [TS Theme](#ts-theme) + - [TS Fonts](#ts-fonts) + - [TS Icons](#ts-icons) + - [Dice Finder](#dice-finder) +6. [FAQ](#faq) +7. [Upgrade Guides](#upgrade) + +## What are Symbiotes? + +Symbiotes are our first supported way of modding, allowing you, the community, to make character sheets, fancy dice rollers, hand-out notes, and so much more! +Thanks to the [API](#api-docs), these Symbiotes can load data directly from the game enabling tight integrations. Additionally, you can load regular websites if you prefer, to just use your favorite online resource without having to Alt+Tab out all the time. + +## Getting Started + +If you are not a developer or just want a simple introduction into what Symbiotes are and how to use them, visit [this page](https://symbiote-docs.talespire.com/user_docs.html) or watch this [video guide](https://www.youtube.com/watch?v=znOcQKJFFpU). If you are a developer, read on. + +Some parts of the documentation contain operating system specific information like file paths. Select the operating system you plan on developing on from the following selector to show the relevant documentation. + +To understand how web view based Symbiotes work and create your own, check out the examples provided on our [GitHub repository](https://github.com/Bouncyrock/symbiotes-examples). The very first step is to activate the feature in the settings: ![](../../_resources/enable_symbiotes_0bf2f1600a7f442da4cbdcaeb3b6ac2c.png) +If you just want to use other people's Symbiotes you're now done and can [install](#installing) the ones you want to use. If you want to make your own Symbiotes, you need to create a [manifest](#manifest-docs) that describes the Symbiote you want to make. For some Symbiotes this may be sufficient, but for most, you will also need to create an HTML file to be loaded. +Additionally, we provide templated creation within TaleSpire to set up a mostly blank Symbiote for you to work with. To use it, enable the development mode and then press "Create new Symbiote" in the bottom of the Symbiotes panel: + +![](../../_resources/dev_mode_6cdd1a2477554097a2a13443748cb431.png) + +If you need additional help after reading through the documentation, check out the `#talespire-modding` channel on our [Discord](https://discord.gg/talespire). + +### Installing a Symbiote + +To install a Symbiote, simply open the library and open the "Community Mods" tab. + +![](../../_resources/mod_browser_ed550074b1fb484f9dd7f75869cc6053.png) + +In there, open the "Symbiotes" section. This shows all Symbiotes available on the [mod.io](https://mod.io/g/talespire) repository, together with a search box. There, search for the Symbiote you want to install and then simply click on it to download and install. All installed mods will be shown in the "Installed" section where they can be removed and updated. + +Whenever you want to release your Symbiote to the public you can use the [mod.io](https://mod.io/g/talespire) repository. All mods uploaded ([video guide](https://www.youtube.com/watch?v=UDLcQw9DIhA)) there will be searchable and downloadable through the in-game mod browser. In the future we want to support third party community repositories in addition to mod.io for the in-game downloader. + +Manual Install + +Symbiotes that aren't uploaded to mod.io can be installed manually. To do this, simply open the Symbiote directory located at `%AppData%\..\LocalLow\BouncyRock Entertainment\TaleSpire\Symbiotes\`. Instead of navigating there manually you can click the "Open Symbiotes Directory" button in either the modding page in the game settings or the Symbiote panel itself. Simply add a folder in there for each Symbiote and drop the Symbiote files into that directory directly. Make sure that the manifest.json is in the root of your Symbiote's folder like so: `[...]\Symbiotes\my_cool_symbiote\manifest.json`. All folder and Symbiote creation/editing is detected live by TaleSpire and will cause an automatic reload on changes meaning TaleSpire does not need to be restarted for any of this. + +#### Mod Downloader vs Manual Install + +When downloading with the in-game mod downloader, Symbiotes are installed to TaleSpire's app cache: `%AppData%\..\LocalLow\BouncyRock Entertainment\TaleSpire\primary\Mods\Symbiotes\`, which is different to the manual install location described above. This means there are two entirely separate locations where Symbiotes can be installed to, one handled by TaleSpire itself and one manually by users - this can of course result in the same Symbiote being installed once manually and once through the mod downloader. For this case keep in mind that the log and storage locations are separate for both installs and that this will cause conflicts in case both use the same interop ID! + +### Development Workflow + +While you can develop the basic structure of your Symbiote in any external browser, features that rely on data or interactions with TaleSpire cannot be tested outside of TaleSpire. When loading a Symbiote in TaleSpire you can connect to it with an external browser by loading [localhost:8080](localhost:8080). For this to work you must first enable Symbiotes debugging in the TaleSpire settings: + +![](../../_resources/dev_mode_6cdd1a2477554097a2a13443748cb431.png) + +The embedded browser is Chromium-based, so you can target that as your platform if you want to just support your project running as a Symbiote in TaleSpire. +Any changes you make to files within the Symbiote directory (excluding files/folders starting with a period: '.') will cause the Symbiote to automatically reload. This means you can have the code open in your favorite IDE and and have the Symbiote reload with each saved change. + +#### Creating a Symbiote + +To create a new Symbiote either use the [templated creation](#getting-started) or create a [manifest.json](#manifest-docs) yourself and add it to the [Symbiotes directory](#manual-install). + +## Manifest Docs + +The list links to the various manifest versions. Note that while deprecated versions are still supported for now, we are phasing them out and you should [update your existing Symbiotes](#upgrade) to a newer version and not create new ones with those versions. + +- [v1](https://symbiote-docs.talespire.com/manifest_doc_v1.html) + +Additional Info + +### Manifest Parser + +When a manifest (= any file called `manifest.json`) gets parsed it can either succeed, which adds the Symbiote to the list of all others, or it can fail due to various reasons. If parsing the manifest fails, the Symbiote will still be shown in the list, but opening it doesn't load the Symbiote, instead it loads an error page. Parsing can fail for a number of obvious reasons: Incorrect manifest version, required fields being missing, the JSON being corrupted and not parseable, etc. +Additionally to all the ways a manifest could contain invalid data, parsing also fails if two installed Symbiotes have the same interop ID. This is true for both Symbiotes, not just one of them. + +## API Docs + +The list links to the various API versions. Note that while deprecated versions are still supported for now, we are phasing them out and you should [update your existing Symbiotes](#upgrade) to a newer version and not create new ones with those versions. API updates that are exclusively additional (eg: a completely new API call) will not bump the version number, this will only be done on changes that potentially change the behavior of existing calls to be able to retain compatibility. + +For practical examples of the API in use, see our [GitHub repository](https://github.com/Bouncyrock/symbiotes-examples) of Symbiote examples. + +- [v0.1](https://symbiote-docs.talespire.com/api_doc_v0_1.md.html) + +Additional Info + +### API reverse domain name + +By default the API functions are accessible under the `TS` object, which is an alias for `com.bouncyrock.talespire`. It may be that this short alias has name conflicts on an existing webpage not made with TaleSpire/Symbiotes in mind, which is why it can be disabled in the [manifest](#manifest-docs). All functions can be accessed by calling `com.bouncyrock.talespire.functionCategory.theFunctionYouWantToCall` instead. + +### Rate limiting + +We reserve the right to block any API call if the Symbiote has sent too many in short succession to preserve performance for TaleSpire. Any blocked calls will return the `rateLimited` error. + +### Permissions + +The API only allows access to information that is accessible through the game normally. This means for example that a Symbiote running on a player's client will only be able to query stats for a mini controlled by them, not for other minis. Similarly a slab cannot be put into the hand of a player, as they don't have permissions to build. There is a distinction between users who *can* be a GM (= they have permission to be GM) and users who *currently are* a GM. Most calls that require a certain permission need the user (= player) to be able to switch to GM mode (`canGM` is set to true), while they don't care about which mode the client is currently in. + +### Injection + +If an API version is specified in the [manifest](#manifest-docs), it is injected by TaleSpire. After this, each extra specified in the extras array in the [manifest](#manifest-docs) is injected individually in order of the array. Finally, if the API was injected the API initialization starts. This guarantees that: + +- you can rely on the API existing (but not initialized) in your injected code, +- you can rely on your injected code to be able to listen to and receive the `hasInitialized` event, and +- you can rely on injections later in the extras array being able to see and edit previous injections like for example: Adding extra regexes to the Dice Finder extra by injecting a .js file after injecting dice finder. + +Scripts loaded by the HTML file itself are loaded according to normal browser behavior which can be before DOMContentLoaded and before API injection. [Deferring](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script) or setting an [event listener for onLoad](https://developer.mozilla.org/en-US/docs/Web/API/Window/load_event) can be used to ensure they are loaded at the appropriate time. The `hasInitialized` event is fired as soon as the API has finished loading and the communication between TaleSpire and the embedded browser is running. + +The entire injection procedure is repeated for every page load. This includes the user navigating away from the entry point by clicking a link or a programmatic page switch using eg: [window.open](https://developer.mozilla.org/en-US/docs/Web/API/Window/open). Because of this it is often best to create Symbiotes as one-pagers that switch out content programmatically if necessary instead of navigating to other pages. + +### Symbiote lifetime + +#### Symbiote Detection + +TaleSpire scans the top-level directories under `%AppData%\..\LocalLow\BouncyRock Entertainment\TaleSpire\Symbiotes\` looking for directories containing a manifest.json file. The manifest is then read, and the Symbiote is validated. The Symbiote is then available in the symbiotes panel. If the validation fails, then the entry in the panel will have a little warning marker indicating that fact. TaleSpire continues watching the Symbiotes directory for newly added or removed Symbiotes. + +#### Starting Symbiote + +When a Symbiote starts, TaleSpire loads the entryPoint specified in the [manifest](#manifest-docs). By default, any additional code [injection](#injection) (such as for the TaleSpire API) begins once the [DOMContentLoaded](https://developer.mozilla.org/en-US/docs/Web/API/Window/DOMContentLoaded_event) event is emitted by the embedded browser, guaranteeing the `hasInitialized` event triggers only once the entire DOM is available. If this is unimportant to you, you can override this by setting the `initializeEarly` option to true in the [manifest](#manifest-docs). Note that `initializeEarly` doesn't guarantee the `hasInitialized` event to trigger before DOMContentLoaded; it just makes it possible that it can happen depending on timing. + +#### In-use lifetime + +If your Symbiote is focused and the Symbiotes panel is closed, your Symbiote will be delivered an event via the `onVisibilityEvent` event source. +If your Symbiote was focused, but then a different Symbiote becomes the focus, the behavior depends on the capabilities of your Symbiote. If the `runInBackground` capability is specified, then the Symbiote will be informed that it `willEnterBackground`, and the Symbiote will continue running. If the `runInBackground` capability is not specified, then the Symbiote is shut down until it becomes focused again. We recommend not using the `runInBackground` capability whenever possible, as it allows TaleSpire to free up resources for the player. Instead, we prefer saving to Symbiote storage after all notable data changes and reloading them on Symbiote startup. + +Symbiotes survive board switches, but some API calls won't work during the switch. For example, `TS.players.getPlayersInThisBoard` doesn't make sense if the Symbiote is not in a board. This should only take a short time but should be handled by the Symbiote by listening for client leave/join events for your own client and suppressing API calls. + +If dev-mode is enabled, TaleSpire watches the symbiote directory for changes and will live-reload the Symbiote when modified. The folders are watched for file changes to see updates to files and detect new Symbiotes being added (or existing ones removed). Note the exceptions to file watching mentioned in [Development Workflow](https://symbiote-docs.talespire.com/dev-workflow). + +#### Shutdown Symbiote + +On shutdown of a Symbiote, we attempt to deliver the `willShutdown` event from the event source `onStateChangeEvent`. This can give the Symbiote a short time to do any last-minute cleanup you want to do before the Symbiote is shut down. However, delivery of the shutdown event is made on a "best effort" and is not guaranteed. Therefore don't leave important tasks for this event. We reserve the right to shut down any Symbiote without event/warning for reasons such as performance or stability. + +## Extras + +While all of the features of the API are documented above, there are some other miscellaneous extras we provide to make creating your own Symbiotes more convenient and make them look more at home in TaleSpire. They can be added by adding their respective name to the "extras" list in the [manifest](#manifest-docs). + +### TS Theme + +If the string `colorStyles` is added to extras, TaleSpire injects a number of CSS variables defining the TaleSpire color scheme, which we highly recommend if you're trying to create a Symbiote that is supposed to fit in with the rest of the UI. This provides easy access to the exact colors TaleSpire's UI uses and they'll be updated with TaleSpire to keep your Symbiote fitting in at no extra work for you. This also allows users to change the UI theme to for example switch to a high contrast theme and have all the Symbiotes adapt to that. + +The theme switching UI is not completed yet and will be released at a later time. This means Symbiotes will only load with the default theme for now. If you want to test the high contrast theme you can edit the CSS vars to point to that theme in the browser dev console manually. + +Detailed Description & Color Name List + +The color descriptions provide info which colors should be used together - you can of course mix and match them in other ways, but please always check any combination with your favorite contrast checker tool. You should aim for a contrast of at least 4.5, though 7 or more is preferable as it helps with legibility, especially for visually impaired people. We don't always achieve this ourselves, but we'll try our best to improve on that front. + +While you can use colors in "any way you want", do not completely misuse a color against its name or description, like using a background color in the foreground. Even if it might look fine now, if we update the color palette it may lead to bad looking or even unreadable Symbiotes. If there is a color missing for what you want to do it is better to create your own color than to "misuse" an existing theme color for something it is not intended for. + +We want to have as few variables as possible, but the goal is that the entirety of a Symbiote's color palette is defined by these colors (if they are intending to replicate TaleSpire's theme that is), without any manually defined color codes. We are happy to receive [feedback](https://feedback.talespire.com/b/Feature-request) about which situations there may be a color missing. + +- `--ts-color-primary`: The primary color for elements like text. Provides high contrast to `--ts-background-primary` + + Example + +- `--ts-color-secondary`: The secondary color for elements like text. Less contrast than the primary color and can be used for disabled elements or less prominent text like descriptions or captions. + + Example + +- `--ts-background-primary`: The primary color for the background. Provides high contrast to `--ts-color-primary`. + + Example + +- `--ts-background-secondary`: The secondary color for the background. Can be used for example for cards that should be visually distinct from the normal background, for alternating color on long lists or tables or for low contrast borders. If visual distinction is essential to be able to use the UI comfortably, consider giving any elements that use this background color on top of `--ts-background-primary` a border with color `--ts-accessibility-border` because the secondary and primary background colors don't provide good contrast with each other. + + Example + +- `--ts-background-tertiary`: The primary color for the background. Even less contrast with `--ts-color-primary` than the other two background colors. Used for similar cases as `--ts-background-secondary`, as well as button backgrounds, but care should be put into contrast in conjunction with `--ts-color-secondary`. For button backgrounds, see also `--ts-button-background`. + + Example + +- `--ts-accessibility-border`: Defines the border color for the high contrast theme for better accessibility. Is transparent (= invisible) in the default theme, but should be used on input elements to create a more accessible experience when needed. When a border is desired in the normal theme use other colors, but to not impede the usefulness of the high contrast theme, consider using one of the foreground colors as border for input elements to provide a high contrast and only using a background colors as border for non-interactive elements (eg: table borders). + + Example + +- `--ts-accessibility-focus`: Defines a focus color for the high contrast theme for better accessibility. This allows keyboard navigation in Symbiotes by providing a visible highlight around the currently focused element. Is transparent (= invisible) in the default theme, but should be set for all focusable elements (usually input elements). Focus should be set using the "outline" css property. See [examples](https://github.com/Bouncyrock/symbiotes-examples) for guidance. + + Example + +- `--ts-color-danger`: An accent color for depicting something potentially dangerous like deleting/clearing data or similar. + + Example + +- `--ts-accent-primary`: The primary TaleSpire accent color. Can be used for background, text and accents like borders. Be mindful of using it (and the accompanying `--ts-accent-hover`) as background color, because it doesn't provide great contrast with `--ts-color-primary`. + + Example + +- `--ts-accent-hover`: An on hover color for `--ts-accent-primary`. Used wherever there should be a highlight on mouse hover (or similar actions) and `--ts-accent-primary` is used. Similar considerations regarding contrast as with `--ts-accent-primary`. + + Example + +- `--ts-accent-background`: A variant of the accent color to serve better as a background. Can be used for example as background color for an element that already has the primary accent color as border. Provides more contrast to `--ts-color-primary` when used in the background than `--ts-accent-primary`. + + Example + +- `--ts-button-background`: The default button background color. Should be used for most buttons, but to provide contrast between important and less important actions in the UI, `--ts-background-tertiary` can be used for those lower priority elements instead. Does not provide good contrast to `--ts-color-secondary`. + + Example + +- `--ts-button-hover`: An on hover color for `--ts-button-background`. Used wherever there should be a highlight on mouse hover (or similar actions) and `--ts-button-background` is used. Similar considerations regarding contrast as with `--ts-button-background` + + Example + +- `--ts-link`: Text color for hyperlinks. + + Example + +- `--ts-link-hover`: An on hover color for `--ts-link`. Used wherever there should be a highlight on mouse hover (or similar actions) and `--ts-link` is used. + + Example + + +### TS Fonts + +If the string `fonts` is added to extras, TaleSpire injects the TaleSpire fonts to be used in CSS. Available fonts: + +- Optimus Princeps: `font-family: OptimusPrinceps;` + This is an example text with the font + +### TS Icons + +If the string `icons` is added to extras, TaleSpire injects CSS classes for each UI icon and some control classes to adjust sizing and behavior. +
An icon can be inserted by providing an icon element and adding the respective class for the icon you want: +`` +
Additionally, the sizing of the icon can be set to one of the following by inserting their respective classes: + +- `ts-icon-xsmall`: 16px +- `ts-icon-small`: 24px +- `ts-icon-medium`: 32px - if no size is specified, it will default to this size +- `ts-icon-large`: 64px +- `ts-icon-xlarge`: 128px + +and a black border can be added by providing `ts-icon-border`. Keep in mind, that the icons are usually pure white, so without a border they will be invisible on a white background. +An icon using all of these would look like this: `` + +It's possible to invert the icon colors to have black icons (with white borders) by adding the `ts-icon-black` class. +See here for a list of icons: [Available Icons](https://symbiote-docs.talespire.com/icons.html) + +### Dice Finder + +If the string `diceFinder` is added to extras, the dice finder functionality will be added to the Symbiote that's being loaded. The dice finder can identify whether text under the mouse cursor looks like a dice roll that TaleSpire can understand. This is meant to work robustly on as many websites as possible, but due to the vast amount of websites and different approaches it may not work on some. For it to work, it also needs to have the [API](#api-docs) injected. + +We have tested the dice finder on a number of different websites to see if it behaves correctly, but it's likely that the dice finder may be broken on some websites that work differently under the hood. If you encounter any problems with dice finder, please [let us know](https://feedback.talespire.com/b/Bug-Reports) which website is being problematic so we can investigate it. + +Because the dice finder is meant to work on as many pages as possible, the depth of integration is limited. For example it will never be able to extract roll names from pages as that information is presented very differently for every page. This means that while the dice finder provides an easy baseline of support for pages that don't support TaleSpire out of the box, it will never be as good as support specifically made for a certain website. + +## FAQ + +### Will there be more features down the road? + +We absolutely want to expand on the Symbiotes API! This is just the first step and we're hoping on vastly expanding the capabilities exposed to Symbiotes via the API + +### Can Symbiotes change things on the board like changing stats of a mini or place tiles? + +Not yet, but this is something we definitely want to tackle for future updates of the API. For now we opted to not include anything that edits "board state", that is Symbiotes can't do anything that gets saved to the board. We chose this to be able to get Symbiotes into your hands sooner as allowing persisting changes through the API needs careful consideration and planning to make it both safe and nice to use. + +### Why does logging in on some websites not work? + +Some websites open popups/new tabs during login, especially when they're using third party authentication like logging in with your Google account. Having this in different tabs is necessary for the login flow to work, however the default behavior of Symbiotes loads all page navigations in the same tab (equivalent to changing the link target from `_blank` to `_self`). If you are having issues with login you can set the `loadTargetBehavior` field in the [manifest](#manifest-docs) to "popup" to allow links to be opened in popups instead of in the main tab. + +### Can I make a Symbiote that shows different UI for players and GMs? + +Yes! You can either query `TS.players` to see whether the player running the Symbiote has GM permission (`canGM` is set to true), or `TS.clients` to see if the game client is currently in GM view or player view (regardless of the permission the player has) and adapt the displayed UI accordingly. + +### Can two Symbiotes talk to each other? + +There's two cases here: + +1. The same Symbiote across two different clients (= two players having the same Symbiote open) - Yes, these can communicate. They just need to have an interop ID set and then they can use `TS.sync` calls +2. Two different Symbiotes on the same machine - No they cannot communicate directly. The Symbiotes API doesn't allow for direct communication between two Symbiotes with different interop IDs (and there mustn't be two Symbiotes with the same interop ID on the same client). If you have a use case for this do let us know about it! + +### What version of Chromium are you using? + +As of right now it's version 111, but don't depend on this being stable and unchanging for certain behaviors in your Symbiotes as we will be updating this without prior notice for security and compatibility reasons. + +## Upgrade Guides + +There are no versions to upgrade to or from yet. \ No newline at end of file diff --git a/backend/Cargo.toml b/backend/Cargo.toml new file mode 100644 index 0000000..678a4d8 --- /dev/null +++ b/backend/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "campaign-manager-backend" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.8", features = ["postgres", "uuid", "runtime-tokio-native-tls", "chrono"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jsonwebtoken = "9" +argon2 = "0.5" +uuid = { version = "1", features = ["v4", "serde"] } +tower-http = { version = "0.5", features = ["cors", "trace"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +dotenvy = "0.15" diff --git a/backend/src/lib.rs b/backend/src/lib.rs new file mode 100644 index 0000000..049ecd2 --- /dev/null +++ b/backend/src/lib.rs @@ -0,0 +1 @@ +// Module entrypoint — reserved for integration tests and shared types. diff --git a/backend/src/main.rs b/backend/src/main.rs new file mode 100644 index 0000000..9a472de --- /dev/null +++ b/backend/src/main.rs @@ -0,0 +1,43 @@ +use axum::{ + http::StatusCode, + response::IntoResponse, + routing::any, + Router, +}; +use tower_http::cors::CorsLayer; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +#[tokio::main] +async fn main() { + // Load .env if present (dev convenience) + let _ = dotenvy::dotenv(); + + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "campaign_manager_backend=debug,tower_http=debug".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let _database_url = std::env::var("DATABASE_URL") + .expect("DATABASE_URL must be set"); + let _jwt_secret = std::env::var("JWT_SECRET") + .expect("JWT_SECRET must be set"); + + // TODO: initialize sqlx connection pool + // let pool = sqlx::PgPool::connect(&database_url).await.expect("failed to connect to DB"); + + let app = Router::new() + .route("/v1/*path", any(not_implemented)) + .layer(CorsLayer::permissive()); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .expect("failed to bind"); + + tracing::info!("listening on {}", listener.local_addr().unwrap()); + axum::serve(listener, app).await.expect("server error"); +} + +async fn not_implemented() -> impl IntoResponse { + (StatusCode::NOT_IMPLEMENTED, "not implemented") +} diff --git a/campaign_manager_design_v7.md b/campaign_manager_design_v7.md new file mode 100644 index 0000000..2623c93 --- /dev/null +++ b/campaign_manager_design_v7.md @@ -0,0 +1,2929 @@ +**TaleSpire** + +**Campaign Manager Symbiote** + +System Design & Architecture + +v0.1 · March 2026 + +This document describes the full system design for the Campaign Manager +Symbiote --- a TaleSpire mod that provides authenticated, multi-group +campaign management with ruleset-agnostic character sheets and +integrated dice rolling. + +**1. Overview** + +The Campaign Manager Symbiote is a full-featured campaign and character +management tool embedded directly into TaleSpire. It allows players and +Game Masters to manage groups, characters, and session data from within +the game --- eliminating the need for external tools during play. + +**1.1 Goals** + +- Authenticated multi-user accounts with persistent data + +- Flexible group model: one player → many groups, one group → many + players + one DM + +- Character portability: one character can appear in multiple groups + or adventures + +- Ruleset abstraction: a plugin-based system that supports D&D 5e + today and other systems tomorrow + +- Native TaleSpire dice integration via the TS API + +- Works entirely inside TaleSpire with no Alt+Tab required + +**1.2 Non-Goals (v1)** + +- Board state editing (placing tiles, moving minis) --- out of scope + per TaleSpire API limits + +- Real-time collaborative editing of the same character sheet + simultaneously + +- Mobile or standalone web app (Symbiote-only for now) + +**2. System Architecture** + +The system is split into three layers: the TaleSpire Symbiote +(frontend), a backend REST + WebSocket API, and a persistent database. +Because Symbiotes run in Chromium, the frontend is standard web tech +(HTML/CSS/JS or a bundled React app). + +**2.1 High-Level Diagram** + ++-----------------------------------+-----------------------------------+ +| **TaleSpire Symbiote (Chromium)** | **Backend Service** | ++-----------------------------------+-----------------------------------+ +| • Auth UI (login / register) | • Auth Service (JWT + refresh | +| | tokens) | +| • Group Dashboard | | +| | • Groups & Membership API | +| • Character Sheet Renderer | | +| | • Characters API | +| • Ruleset Plugin Loader | | +| | • Ruleset Registry | +| • Dice Integration (TS API) | | +| | • Dice History Store | +| • TS.sync for live updates | | +| | • PostgreSQL + Redis | ++-----------------------------------+-----------------------------------+ + +The Symbiote communicates with the backend over HTTPS. For live session +features (dice results visible to all group members) it upgrades to a +WebSocket connection authenticated with the same JWT. + +**2.2 Symbiote Setup** + +The manifest.json declares the entry point, enables the TS API (v0.1), +and requests the colorStyles, fonts, and diceFinder extras so the UI +matches TaleSpire\'s look and dice detection works on any page inside +the Symbiote. + +> { \"apiVersion\": \"v0.1\", +> +> \"extras\": \[\"colorStyles\", \"fonts\", \"diceFinder\"\], +> +> \"interopId\": \"com.yourproject.campaign-manager\", +> +> \"runInBackground\": false, +> +> \"initializeEarly\": false } + +Because board state can shift during play, the Symbiote listens for +client leave/join events and suppresses API calls during board +transitions, in line with the Symbiote lifetime guidelines. + +**3. Authentication** + +Authentication uses short-lived JWT access tokens (15 min) paired with +long-lived refresh tokens (30 days) stored in the Symbiote\'s TS storage +(not localStorage, which is unreliable across Symbiote restarts). + +**3.1 Registration & Login Flow** + +- User enters email + password in the Symbiote login screen + +- POST /auth/register or POST /auth/login → returns { accessToken, + refreshToken, user } + +- Tokens persisted via TS.storage so they survive Symbiote restarts + +- On boot, Symbiote checks stored tokens; if access token expired, + silently refreshes via POST /auth/refresh + +- On 401 from any API call, same silent refresh is attempted before + surfacing an error + +**3.2 Token Storage** + +TaleSpire\'s TS.storage API is used as the persistence layer. This is +per-installation, meaning tokens are not shared between the +mod-downloader install and any manual install of the same Symbiote +(consistent with TaleSpire\'s storage separation rules). + +**3.3 Security Considerations** + +- HTTPS required for all backend communication + +- Passwords hashed server-side with bcrypt (cost factor ≥ 12) + +- Refresh tokens are single-use and rotated on every refresh + +- Rate limiting on auth endpoints: 10 requests / minute per IP + +- Email verification required before joining or creating groups + +**4. Data Model** + +The schema is designed around the following cardinalities, which are +enforced at the API level: + +- 1 Player → N Groups (via membership) + +- 1 Group → N Players + 1 DM + +- 1 Player → N Characters per Group + +- 1 Character → N Groups (portable across adventures) + +**4.1 users** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + id UUID (PK) Yes Unique user + identifier + + email VARCHAR(255) Yes Unique, used for + login + + password_hash TEXT Yes bcrypt hash, never + returned by API + + display_name VARCHAR(100) Yes Shown in group + member lists + + email_verified BOOLEAN Yes Must be true to + join/create groups + + created_at TIMESTAMPTZ Yes Account creation + timestamp + --------------------------------------------------------------------------- + +**4.2 groups** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + id UUID (PK) Yes Unique group + identifier + + name VARCHAR(150) Yes Campaign or + adventure name + + dm_user_id UUID (FK → Yes Exactly one DM per + users) group + + ruleset_id VARCHAR(100) Yes e.g. \'dnd5e\', + \'pathfinder2e\' + + description TEXT No Campaign blurb / + notes + + created_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------- + +**4.3 group_members (join table)** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + group_id UUID (FK → Yes Composite PK with + groups) user_id + + user_id UUID (FK → Yes Composite PK with + users) group_id + + role ENUM(player, dm) Yes DM is also stored + here for query + convenience + + joined_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------- + +**4.4 characters** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + id UUID (PK) Yes + + owner_user_id UUID (FK → Yes The player who owns + users) this character + + ruleset_id VARCHAR(100) Yes Must match group + ruleset when + assigned + + name VARCHAR(150) Yes Character name + + sheet_data JSONB Yes Ruleset-specific + stats blob (see §6) + + portrait_url TEXT No Optional character + portrait image URL + + created_at TIMESTAMPTZ Yes + + updated_at TIMESTAMPTZ Yes Updated on any + sheet_data change + --------------------------------------------------------------------------- + +**4.5 group_characters (assignment table)** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + group_id UUID (FK → Yes Composite PK + groups) + + character_id UUID (FK → Yes Composite PK + characters) + + user_id UUID (FK → Yes Which player brought + users) this character + + is_active BOOLEAN Yes false = retired / + benched for this + campaign + + assigned_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------- + +A character can be assigned to multiple groups simultaneously (one row +per group_characters). The owner retains full editing rights regardless +of which groups the character is in. + +**5. REST API Reference** + +Base URL: https://api.campaign-manager.example.com/v1 All endpoints +require Authorization: Bearer \ unless marked \[public\]. + +**5.1 Auth** + + ------------------------------------------------------------------------- + **Method + **Auth** **Description** + Path** + ---------------- ----------- -------------------------------------------- + POST Public Create a new user account + /auth/register + + POST /auth/login Public Authenticate; returns JWT pair + + POST Refresh Rotate refresh token; issue new access token + /auth/refresh token + + POST Bearer Revoke refresh token + /auth/logout + ------------------------------------------------------------------------- + +**5.2 Groups** + + --------------------------------------------------------------------------------- + **Method + Path** **Description** + ----------------------------- --------------------------------------------------- + GET /groups List all groups the authenticated user is a member + of (any role) + + POST /groups Create a new group; caller becomes DM automatically + + GET /groups/:id Get group detail including member list + + PATCH /groups/:id Update name/description/ruleset (DM only) + + DELETE /groups/:id Delete group (DM only); cascades membership & + character assignments + + POST /groups/:id/invite DM invites a player by email; creates a pending + invite + + POST /groups/:id/join/:token Player accepts an invite link + + DELETE Remove player from group (DM or self-leave) + /groups/:id/members/:userId + --------------------------------------------------------------------------------- + +**5.3 Characters** + + ---------------------------------------------------------------------------------- + **Method + Path** **Description** + ------------------------------ --------------------------------------------------- + GET /characters List all characters owned by the authenticated user + + POST /characters Create a new character (owner = caller) + + GET /characters/:id Get full character including sheet_data + + PUT /characters/:id Full sheet update; bumps updated_at + + PATCH /characters/:id Partial update (e.g. HP change only) + + DELETE /characters/:id Delete character; removes all group assignments + + POST /groups/:id/characters Assign an owned character to this group + + DELETE Unassign character from group + /groups/:gid/characters/:cid + + GET /groups/:id/characters List all characters assigned to this group (DM sees + all; player sees own) + ---------------------------------------------------------------------------------- + +**5.4 Dice History** + + ----------------------------------------------------------------------- + **Method + Path** **Description** + ------------------- --------------------------------------------------- + POST Log a dice roll result (called automatically by + /groups/:id/rolls Symbiote) + + GET Retrieve recent roll history for a group session + /groups/:id/rolls + ----------------------------------------------------------------------- + +**6. Ruleset Abstraction Layer** + +The game logic is completely isolated behind a Ruleset interface. Each +supported game system ships as a ruleset plugin --- a self-contained JS +module that implements a fixed interface. The core Symbiote code never +touches system-specific rules directly. + +**6.1 Ruleset Interface** + +Every ruleset plugin must export the following contract: + +> interface Ruleset { +> +> id: string // e.g. \'dnd5e\' +> +> name: string // e.g. \'D&D 5th Edition\' +> +> version: string // semver +> +> defaultSheet(): SheetData // blank character template +> +> validate(sheet: SheetData): ValidationResult +> +> getStatBlocks(sheet: SheetData): StatBlock\[\] +> +> getDiceActions(sheet: SheetData): DiceAction\[\] +> +> renderSheet(sheet: SheetData): VNode // framework-agnostic VDOM +> +> } + +**6.2 SheetData** + +sheet_data in the database is an opaque JSONB blob from the backend\'s +perspective. The ruleset plugin is the only code that reads or writes +it. This means adding a new ruleset requires zero database migrations +--- only a new plugin. + +**6.3 DiceAction** + +Rulesets expose dice actions so the core Symbiote can render roll +buttons without knowing the game system. Each action produces a +TaleSpire-compatible dice string: + +> interface DiceAction { +> +> label: string // \'Attack Roll\', \'Perception Check\', etc. +> +> diceString: string // TaleSpire format: \'1d20+5\', \'2d6\', etc. +> +> category: string // \'attack\' \| \'skill\' \| \'save\' \| \'damage\' +> \| \'custom\' +> +> } + +**6.4 Bundled Rulesets (v1)** + +- dsa5e --- Das Schwarze Auge 5. Edition (The Dark Eye 5e): attributes + (MU, KL, IN, CH, FF, GE, KO, KK), derived values (LeP, AsP, KaP), + talents, combat techniques, special abilities, and liturgical/spell + pages + +- generic --- A minimal fallback: name, HP, arbitrary key-value stat + pairs. Useful for one-shots or homebrew systems not yet covered by a + plugin. + +Future rulesets (D&D 5e, Pathfinder 2e, Call of Cthulhu, etc.) are added +by dropping a new plugin into the rulesets/ directory and registering +its id --- zero database migrations required. + +**7. Dice Integration** + +Dice integration uses two complementary TaleSpire mechanisms: the +programmatic dice API and the diceFinder extra. + +**7.1 Programmatic Rolls (Character Sheet Buttons)** + +When a player clicks a dice action button on their character sheet, the +Symbiote calls the TaleSpire dice API directly with the dice string +produced by the ruleset plugin: + +> const action = ruleset.getDiceActions(sheet).find(a =\> a.label === +> \'Attack Roll\'); +> +> await TS.dice.putDiceInHand(action.diceString); + +This puts dice into the player\'s hand exactly as if they had dragged +them from the dice tray, preserving the native TaleSpire roll +experience. + +**7.2 diceFinder (External Pages)** + +When the Symbiote is used to load an external reference site (e.g. a +spell compendium), the diceFinder extra automatically detects text that +looks like a dice roll and makes it clickable --- no extra code +required. + +**7.3 Roll History** + +After a roll resolves, the Symbiote listens on the TS dice event source +and logs the result to the backend (POST /groups/:id/rolls). This gives +the DM a real-time roll history panel during the session, and players +can see their own recent rolls on the character sheet. + +> TS.dice.onDiceResult.addListener(async (result) =\> { +> +> await api.post(\`/groups/\${currentGroup.id}/rolls\`, { +> +> character_id: activeCharacter.id, +> +> dice_string: result.diceString, +> +> total: result.total, +> +> breakdown: result.breakdown, +> +> }); +> +> }); + +**8. Live Session Sync** + +For features that need all group members to see updates in near +real-time (HP changes, roll history), the Symbiote uses a combination of +TaleSpire\'s own TS.sync and a backend WebSocket channel. + +**8.1 TS.sync (same Symbiote, different clients)** + +For lightweight state (e.g. \'player X rolled initiative\'), TS.sync +messages are broadcast to all clients running the same Symbiote in the +same board. This requires no backend infrastructure and has very low +latency. + +**8.2 Backend WebSocket (persistent, cross-session)** + +For data that must persist (HP updates, saved rolls), the Symbiote also +sends changes to the backend REST API. The backend can then push updates +to other connected clients via a WebSocket room keyed to the group ID. + +**8.3 Conflict Resolution** + +Last-write-wins is used for most character fields. For HP specifically, +delta updates (\'+5 HP\' rather than \'set HP to 45\') are preferred to +reduce merge conflicts during fast-paced combat rounds. + +**9. Symbiote Frontend Structure** + +The Symbiote is built as a single-page application (no navigation +between pages, as recommended by TaleSpire to avoid losing the injected +API). Views are swapped programmatically. + +**9.1 View Routing** + +- Login / Register → shown when no valid tokens in TS.storage + +- Group List → home screen after login + +- Group Detail → member list, character roster, DM tools + +- Character Sheet → full sheet view with dice buttons + +- Character Select → modal to assign an existing character to a group + +- Roll History → side panel, always accessible during a session + +**9.2 Styling** + +The colorStyles extra is loaded so all colors reference TaleSpire CSS +variables (\--ts-background-primary, \--ts-color-primary, etc.), keeping +the UI consistent with the game\'s theme and automatically supporting +future high-contrast modes. + +**9.3 Icons & Fonts** + +The fonts extra provides OptimusPrinceps for headings (matching +TaleSpire\'s fantasy aesthetic) while body text uses a clean sans-serif. +The icons extra provides ts-icon-\* classes for action buttons (dice, +edit, delete) without requiring external icon libraries. + +**10. Recommended Tech Stack** + + ------------------------------------------------------------------------ + **Layer** **Technology** **Rationale** + ------------------ ------------------- --------------------------------- + Symbiote UI Svelte Reactive, minimal bundle; no + virtual DOM overhead inside + Chromium + + Auth JWT Stateless tokens; argon2id + (jsonwebtoken) + preferred over bcrypt for new + argon2 systems + + Backend Rust + Axum Memory-safe, high-throughput; + strong async story with Tokio + + ORM / Queries sqlx Compile-time checked queries + against PostgreSQL; no ORM magic + + Database PostgreSQL 16 JSONB for sheet_data; strong + relational integrity; no PVC + needed in k8s + + Object Storage S3-compatible (e.g. Portrait images and .cmchar + Cloudflare R2) exports; avoids PVC entirely in + Kubernetes + + Hosting Kubernetes (no PVC) Stateless pods; all persistence + in Postgres + S3 + ------------------------------------------------------------------------ + +**11. Companion Web App** + +The companion web app (campaign-manager.example.com) extends the +Campaign Manager beyond TaleSpire. Players can create and edit +characters, import them from external tools, and manage their group +memberships from any browser --- before or between sessions --- without +TaleSpire open. The Symbiote and web app share the same backend API and +the same user accounts; there is no separate auth system. + +**11.1 Scope** + ++-----------------------------------+-----------------------------------+ +| **In Scope (Web App)** | **Symbiote Only** | ++===================================+===================================+ +| Create and edit characters (full | Live dice rolling via TS API | +| sheet) | | +| | Real-time roll history during | +| Import characters from JSON / | session | +| .cmchar file | | +| | TS.sync group communication | +| Manual character entry by ruleset | | +| | Board-aware player/GM detection | +| Browse and manage groups | | +| | | +| Invite players and assign | | +| characters | | +| | | +| Export characters as .cmchar JSON | | ++-----------------------------------+-----------------------------------+ + +**11.2 Authentication** + +The web app uses the same /auth/\* endpoints as the Symbiote. Access +tokens are held in memory only; refresh tokens are stored in an +HttpOnly, Secure, SameSite=Strict cookie --- more appropriate than +TS.storage (which is Symbiote-specific) and safer than localStorage. + +- Login and register pages are fully public routes + +- All other routes are protected; unauthenticated requests redirect to + /login + +- On hard refresh, the app silently calls POST /auth/refresh via the + cookie before rendering any protected page + +- Logout revokes the refresh token server-side and clears the cookie, + then redirects to /login + +**11.3 Character Import** + +The import flow accepts two sources: the Campaign Manager\'s own .cmchar +export format, and generic JSON from any external tool via a field +mapper. PDF, D&D Beyond, and other integrations are explicitly out of +scope for v1. + +**11.3.1 Campaign Manager .cmchar Format** + +Any character exported from the web app or Symbiote produces a .cmchar +file --- JSON with a fixed envelope. Importing one requires no field +mapping: + +> { +> +> \"cm_version\": \"1.0\", +> +> \"ruleset_id\": \"dnd5e\", +> +> \"name\": \"Aelindra Swiftwind\", +> +> \"sheet_data\": { /\* ruleset-specific blob \*/ } +> +> } + +The backend validates ruleset_id against the known registry, then passes +sheet_data through the ruleset plugin validate() call. Field-level +errors are surfaced in the UI before any record is created. + +**11.3.2 Generic JSON Import (Field Mapper)** + +For exports from Roll20, Foundry VTT, or custom spreadsheets, the web +app provides a two-column field mapper. The user uploads any JSON file; +the left column shows detected source fields, the right shows Campaign +Manager target fields for the chosen ruleset. The user connects them by +drag or dropdown, previews the mapped result, and confirms. + +- Required target fields (name, ruleset) must be mapped before import + is allowed + +- Unmapped optional fields are silently dropped --- no partial saves + are blocked + +- A completed mapping can be saved as a named template for re-use + across future imports of the same tool\'s exports + +- The same ruleset validate() call runs on the mapped result before + the character record is created + +**11.3.3 Manual Entry** + +A blank character is created by selecting a ruleset from a dropdown, +which immediately renders the empty sheet form. The form is produced by +the same ruleset plugin renderSheet() method used in the Symbiote, so +both surfaces are always in sync. Auto-save drafts to the backend every +30 seconds to protect against accidental tab closure. + +**11.4 Character Editor** + +The full character editor is a single-page form rendered by the active +ruleset plugin. Saving issues a PUT /characters/:id. Because both +clients share the backend, a character saved on the web app is +immediately available inside TaleSpire --- the Symbiote re-fetches on +load or on receiving a WebSocket push. + +- Optimistic UI: changes are reflected locally immediately; on save + failure the previous value is restored with a toast notification + +- Edit history: the backend retains the last 10 versions of sheet_data + per character, accessible via GET /characters/:id/history, so + accidental overwrites can be undone + +- Portrait upload: drag-and-drop image accepted, validated for MIME + type and size (max 2 MB) client-side, then uploaded directly to + object storage via a backend-issued presigned URL; the resulting URL + is stored on the character record + +**11.5 Group Management** + +The group dashboard gives DMs and players a comfortable full-screen view +of their campaigns. Pages and their access rules are: + + ----------------------------------------------------------------------------- + **Route** **Access** **Description** + ------------------------ ------------ --------------------------------------- + /groups All Card grid of every group the user + belongs to; create new group button + + /groups/:id Member Member list with roles, character + roster per player, pending invites + + /groups/:id/settings DM only Rename, change ruleset, transfer DM + role, delete group + + /groups/:id/invite DM only Generate a shareable link or send + invite directly by email + + /characters Owner Full character library; import, create, + export, or delete characters + + /characters/:id Owner Full character editor rendered by the + ruleset plugin + + /characters/:id/export Owner Download the character as a .cmchar + JSON file + ----------------------------------------------------------------------------- + +**11.6 Tech Stack** + + ------------------------------------------------------------------------ + **Layer** **Technology** **Rationale** + ---------------- ------------------ ------------------------------------ + Framework Svelte + Vite Same component model as the Symbiote + UI; shared ruleset plugin code + + Styling CSS custom Mirror the TS colorStyles variables; + properties both UIs stay visually consistent + + Auth state Svelte store + No token ever touches JS memory + HttpOnly cookie beyond the active session + + API layer Centralized fetch Shared with Symbiote wrapper; + client auto-refresh on 401 + + File handling File API + JSON parsed client-side before + FileReader upload; no server-side temp file + storage + + Image upload Presigned S3 URL Client uploads direct to + (no PVC) S3-compatible storage; backend + stores URL only; k8s pods stay + stateless + + Hosting Static CDN + Web app is a static build; shares + existing API the Axum API server with the + Symbiote + ------------------------------------------------------------------------ + +**11.7 CORS & Security** + +- Backend CORS allowlist: the web app domain and localhost:8080 + (Symbiote dev mode). No wildcard origins. + +- CSRF protection via SameSite=Strict on the refresh token cookie --- + no additional CSRF token required + +- Content-Security-Policy: default-src \'self\'; connect-src + restricted to the known API origin; no inline scripts + +- JSON import validation: files are parsed and schema-checked entirely + client-side before any data is sent to the backend + +- Image uploads: MIME type checked against an allowlist (image/png, + image/jpeg, image/webp) and size capped at 2 MB client-side; backend + re-validates before issuing the presigned URL + +**11.8 Sync Between Web App and Symbiote** + +Both clients write to the same REST API so data is always consistent at +rest. For changes made while a session is active there are two +scenarios: + +- HP change mid-combat (web app to Symbiote): PATCH /characters/:id + updates the backend; backend pushes a WebSocket event to the group + room; Symbiote receives and re-renders. Typical latency under 500 + ms. + +- Backstory edit between sessions (web app): no live sync needed. The + Symbiote fetches the latest sheet_data on its next startup. + +The web app does not implement TS.sync --- that channel is internal to +TaleSpire. All cross-client communication routes through the backend +WebSocket room keyed to the group ID. + +**13. Content Service** + +The Content Service is a standalone Rust + Axum microservice with its +own PostgreSQL database, deployed independently of the Campaign Service. +It is the authoritative source for all ruleset reference data: monsters, +items, spells, talents, rules, rulebooks, and inter-content +dependencies. The Campaign Service calls the Content Service at runtime +to power searchable pickers, derived stat calculations, and rule +validation in the character editor. + +**13.1 Why a Separate Service** + + ----------------------------------------------------------------------- + **Concern** **Rationale for Separation** + ----------------------------------- ----------------------------------- + Data ownership Ruleset content is shared global + data; character/group data is + per-user. Different access + patterns, different retention + rules. + + Copyright sensitivity Rulebook text and official stat + blocks may carry licensing + restrictions. Isolating them in a + dedicated service makes it easier + to apply different access controls + or redact content per jurisdiction. + + Scaling Content reads are high-volume and + cache-friendly. Campaign writes are + low-volume and transactional. + Independent scaling avoids + coupling. + + Deployment cadence New ruleset content ships on its + own schedule, independently of auth + or character sheet fixes. + + Rust workspace Each service is a separate crate in + a Cargo workspace, sharing common + types (content_types, + campaign_types) via internal + library crates. + ----------------------------------------------------------------------- + +**13.2 Content Data Model** + +All content belongs to a ruleset and is versioned. The ruleset_id +foreign key ties every content record to a specific game system. The +schema is intentionally flat and generic at the top level, with a JSONB +data blob for system-specific fields --- the same pattern as sheet_data +in the Campaign Service. + +**rulesets (content DB)** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + id VARCHAR(100) Yes Slug, e.g. + (PK) \'dsa5e\', \'dnd5e\' + + name VARCHAR(200) Yes Display name, e.g. + \'Das Schwarze Auge + 5e\' + + version VARCHAR(50) Yes Ruleset schema + version, semver + + maintainer_notes TEXT No Shown in the editor + UI + + created_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------- + +**content_entries** + + --------------------------------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------------------------------- -------------- -------------------- + id UUID (PK) Yes + + ruleset_id VARCHAR(100) (FK) Yes Links to rulesets.id + + entry_type VARCHAR(50) Yes One of: spell, + talent, item, + monster, rule, + rulebook, condition, + trait, + special_ability + + slug VARCHAR(200) Yes URL-safe unique + identifier within a + ruleset+type, e.g. + \'fireball\' + + name VARCHAR(300) Yes Display name + + source_book VARCHAR(200) No Publication + reference, e.g. + \'Core Rulebook + p.142\' + + data JSONB Yes Type-specific + fields; validated + against entry_type + schema on write + + status ENUM(draft,review,approved,deprecated) Yes Content moderation + state + + created_by UUID (FK → users) Yes Author of the + submission + + approved_by UUID (FK → users) No NULL until approved + + created_at TIMESTAMPTZ Yes + + updated_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------------------------------- + +**content_dependencies** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + from_entry_id UUID (FK) Yes Composite PK with + to_entry_id + + dep_type + + to_entry_id UUID (FK) Yes The required entry + + dep_type VARCHAR(50) Yes One of: requires, + excludes, grants, + replaces, modifies + + condition JSONB No Optional predicate, + e.g. { min_level: 3 + } + --------------------------------------------------------------------------- + +**ruleset_maintainers** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + ruleset_id VARCHAR(100) Yes Composite PK + (FK) + + user_id UUID (FK → Yes Composite PK + users) + + can_approve BOOLEAN Yes true = can move + entries to approved + status + + granted_by UUID (FK → Yes Admin who assigned + users) this role + + granted_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------- + +The content_dependencies table captures the dependency graph between +entries. For DSA5e this covers cases like: a special ability requires a +minimum talent level, a spell has a prerequisite spell, or two traits +are mutually exclusive. The Campaign Service walks this graph during +character validation. + +**13.3 entry_type JSONB Schemas** + +Each entry_type has a JSON Schema registered in the Content Service. +Writes are validated server-side against the schema for the declared +type. This means the Campaign Service and editor UI can always trust +that data blobs are well-formed. Example schemas for DSA5e: + +**talent** + +> { name, category, check_attributes: \[attr, attr, attr\], +> +> encumbrance: bool, improvement_cost: \'A\'\|\'B\'\|\'C\'\|\'D\' } + +**spell** + +> { name, tradition, property, casting_time, cost: { asp: int }, +> +> range, duration, target, effect, enhancement_levels: \[\...\] } + +**monster** + +> { name, size, lep, ini, at, pa, rs, mr, gs, +> +> attacks: \[{ name, dice, damage_type }\], loot: \[\...\] } + +**item** + +> { name, item_type, weight, price, availability, +> +> combat_stats?: { at_mod, pa_mod, damage, reach } } + +**13.4 Access Control & Moderation** + +Content goes through a three-stage lifecycle: draft → review → approved. +Only approved entries are returned by the public read API that the +Campaign Service calls. This keeps unreviewed community submissions from +reaching character sheets. + + ----------------------------------------------------------------------- + **Role** **Permissions** + ------------------- --------------------------------------------------- + Any registered user Submit new entries (status = draft); edit own draft + entries; view all approved entries + + Ruleset maintainer All of the above, plus: move entries to review or + approved; edit any entry in their ruleset; + deprecate entries + + Admin All of the above across all rulesets; grant/revoke + maintainer roles; delete entries; manage rulesets + ----------------------------------------------------------------------- + +- A maintainer can_approve flag distinguishes senior maintainers (who + can publish) from junior contributors (who can submit to review but + not approve) + +- Approval of an entry that has unresolved dependency entries is + blocked --- all deps must be approved first + +- Deprecation is soft: deprecated entries remain on existing character + sheets but are hidden from pickers in the editor + +**13.5 Content Service API** + +Base URL: https://content.campaign-manager.example.com/v1 + +**Public Read Endpoints (called by Campaign Service and web editors)** + + --------------------------------------------------------------------------- + **Method + Path** **Description** + ----------------------------- --------------------------------------------- + GET /rulesets List all rulesets with entry counts + + GET /rulesets/:id/entries Search entries; filter by entry_type, text + query, status. Returns approved only unless + caller is maintainer/admin. + + GET /entries/:id Full entry including data blob + + GET /entries/:id/dependencies Full resolved dependency graph (recursive) + + POST Given a partial sheet_data, check all dep + /entries/:id/validate-sheet constraints. Returns list of violations. + --------------------------------------------------------------------------- + +**Authenticated Write Endpoints (users, maintainers, admins)** + + -------------------------------------------------------------------------------- + **Method + Path** **Description** + ---------------------------------- --------------------------------------------- + POST /rulesets/:id/entries Submit a new entry (status = draft) + + PATCH /entries/:id Edit an entry (own drafts, or + maintainer/admin for any) + + POST /entries/:id/status Transition status (maintainer: + draft→review→approved; admin: any) + + DELETE /entries/:id Hard delete (admin only; soft deprecation + preferred) + + POST /entries/:id/dependencies Add a dependency edge + + DELETE Remove a dependency edge + /entries/:id/dependencies/:depId + + POST /rulesets/:id/maintainers Grant maintainer role (admin only) + -------------------------------------------------------------------------------- + +**13.6 Service-to-Service Communication** + +The Campaign Service calls the Content Service over internal HTTP +(cluster-internal DNS in Kubernetes). Calls use a shared service account +JWT, not a user token. There are three integration points: + +- Picker search: when a user opens a talent or spell picker in the + character editor, the Campaign Service proxies a GET + /rulesets/:id/entries request with the user\'s search term and + returns the results to the Svelte frontend. The frontend never calls + the Content Service directly. + +- Derived stat calculation: the campaign service fetches the relevant + entries (weapon, talent, attributes) and passes them to the ruleset + plugin\'s calculate() function, which returns updated derived + values. The result is written back to sheet_data. + +- Validation: on every character save (PUT /characters/:id), the + Campaign Service calls POST /entries/:id/validate-sheet for each + selected ability or item. Any violations are returned as a + structured error list before the save is committed. + +Response caching: approved content is immutable once published. The +Campaign Service caches Content Service responses in-process (a simple +DashMap\ in Rust) with a 5-minute TTL, reducing +cross-service latency for the most common picker lookups. + +**13.7 Content Editor Web App** + +A second web application (content.campaign-manager.example.com) provides +a full editing UI for the content database, separate from the character +management web app. It is a Svelte + Vite static build deployed to the +same CDN. + +**Editor Pages** + + ----------------------------------------------------------------------------------- + **Route** **Access** **Description** + ------------------------------ ------------ --------------------------------------- + / All Ruleset browser; search across all + approved content + + /:rulesetId All Ruleset overview: entry counts by type, + maintainer list, recent activity + + /:rulesetId/entries All Filterable table of all entries; status + badges; click to view + + /:rulesetId/entries/new Auth user Entry submission form; type selector + renders appropriate JSONB fields + + /:rulesetId/entries/:id All Full entry view with dependency graph + (approved) / visualisation + Auth (draft) + + /:rulesetId/entries/:id/edit Owner / Full entry editor with field validation + Maintainer / against entry_type schema + Admin + + /review-queue Maintainer / All entries in draft or review status + Admin awaiting action + + /admin/maintainers Admin Grant and revoke maintainer roles per + ruleset + ----------------------------------------------------------------------------------- + +**Dependency Graph View** + +The entry detail page renders the dependency graph as an interactive SVG +using a force-directed layout. Nodes are colour-coded by entry_type; +edges are labelled with dep_type (requires, excludes, grants, replaces, +modifies). Clicking any node navigates to that entry. This gives +maintainers an immediate visual overview of complex prerequisite chains +--- especially important for DSA5e\'s special ability trees. + +**Entry Form** + +The submission and edit forms are data-driven: selecting an entry_type +loads the JSON Schema for that type and renders the appropriate fields +dynamically. Required fields are marked; enum fields render as +dropdowns; nested arrays (e.g. enhancement_levels on a spell) render as +repeatable field groups. Client-side schema validation gives inline +errors before the API is called. + +**13.8 Storage --- No PVC** + +The Content Service, like the Campaign Service, runs with no Persistent +Volume Claims in Kubernetes. All persistence is in the Content +Service\'s dedicated PostgreSQL instance. There is no file storage in +the Content Service --- rulebook PDFs or source images are out of scope; +the service stores only structured data. + +**13.9 Shared Auth** + +Both services validate JWTs issued by the same auth system (shared +public key). The user identity (user_id, email) and a roles claim are +embedded in the token. The roles claim includes an array of ruleset +maintainer grants, e.g.: + +> { \"sub\": \"uuid\", \"roles\": \[\"maintainer:dsa5e\", \"admin\"\] } + +This means the Content Service does not need to call the Campaign +Service to check permissions --- it reads the token directly. The +Campaign Service issues tokens; both services consume them. + +**14. Deployment & Repository Layout** + +The project is a monorepo with two top-level workspaces: a Cargo +workspace for the two Rust backend services, and a pnpm workspace for +the three Svelte frontend surfaces. The Symbiote is not a Docker image +--- it is a static file package distributed via mod.io. The two web +frontends and the two backend services each produce a Docker image, for +four images total. + +**14.1 Monorepo Structure** + +> campaign-manager/ +> +> ├── backend/ \# Cargo workspace +> +> │ ├── Cargo.toml \# workspace root +> +> │ ├── common/ \# shared library crate +> +> │ │ └── src/ \# JWT types, error types, content_types, campaign_types +> +> │ ├── campaign-service/ \# binary crate +> +> │ │ ├── Cargo.toml +> +> │ │ ├── Dockerfile +> +> │ │ └── src/ +> +> │ └── content-service/ \# binary crate +> +> │ ├── Cargo.toml +> +> │ ├── Dockerfile +> +> │ └── src/ +> +> ├── frontend/ \# pnpm workspace +> +> │ ├── package.json \# workspace root +> +> │ ├── packages/ +> +> │ │ ├── ruleset-core/ \# plugin interface + DSA5e plugin +> +> │ │ ├── ui-components/ \# shared Svelte components +> +> │ │ └── api-client/ \# shared fetch wrapper (auto-refresh) +> +> │ └── apps/ +> +> │ ├── symbiote/ \# TaleSpire Symbiote --- NOT a Docker image +> +> │ │ ├── manifest.json +> +> │ │ └── src/ +> +> │ ├── campaign-web/ \# Docker image +> +> │ │ ├── Dockerfile +> +> │ │ └── src/ +> +> │ └── content-editor/ \# Docker image +> +> │ ├── Dockerfile +> +> │ └── src/ +> +> ├── k8s/ \# Kubernetes manifests +> +> │ ├── campaign-service/ +> +> │ ├── content-service/ +> +> │ ├── campaign-web/ +> +> │ ├── content-editor/ +> +> │ └── ingress/ +> +> └── .github/workflows/ \# CI/CD pipelines + +**14.2 Docker Images** + +All four Docker images use multi-stage builds. The Rust images compile +in a builder stage and copy only the final static binary into a minimal +runtime image, keeping production images small and attack-surface +minimal. + +**14.2.1 campaign-service and content-service (Rust)** + +> \# backend/campaign-service/Dockerfile +> +> FROM rust:1.77-slim AS builder +> +> WORKDIR /build +> +> COPY . . +> +> RUN cargo build \--release -p campaign-service +> +> FROM debian:bookworm-slim +> +> RUN apt-get update && apt-get install -y ca-certificates && rm -rf +> /var/lib/apt/lists/\* +> +> COPY \--from=builder /build/target/release/campaign-service +> /usr/local/bin/ +> +> EXPOSE 8080 +> +> CMD \[\"campaign-service\"\] + +The content-service Dockerfile is identical with the binary name +swapped. Final images land under 30 MB. The Cargo workspace COPY means +both Dockerfiles copy the full workspace, so the common crate is always +available --- Docker layer caching on the dependency compile step keeps +rebuild times fast in CI. + +**14.2.2 campaign-web and content-editor (Svelte)** + +> \# frontend/apps/campaign-web/Dockerfile +> +> FROM node:20-slim AS builder +> +> RUN npm install -g pnpm +> +> WORKDIR /build +> +> COPY frontend/ . +> +> RUN pnpm install \--frozen-lockfile +> +> RUN pnpm \--filter campaign-web build +> +> FROM nginx:alpine +> +> COPY \--from=builder /build/apps/campaign-web/dist +> /usr/share/nginx/html +> +> COPY frontend/apps/campaign-web/nginx.conf +> /etc/nginx/conf.d/default.conf +> +> EXPOSE 80 + +The nginx.conf sets try_files \$uri \$uri/ /index.html for SPA routing +and adds cache headers: immutable long-cache for hashed assets, no-store +for index.html. The content-editor Dockerfile is identical with the app +name swapped. + +**14.2.3 Image Summary** + + ------------------------------------------------------------------------------- + **Image** **Base** **Est. **Serves** + Size** + --------------------- ---------------------- ----------- ---------------------- + campaign-service debian:bookworm-slim \~25 MB Auth, groups, + characters, dice + history + + content-service debian:bookworm-slim \~25 MB Rulesets, entries, + dependencies, + moderation + + campaign-web nginx:alpine \~15 MB Campaign management + Svelte SPA + + content-editor nginx:alpine \~12 MB Content editor Svelte + SPA + + symbiote N/A \~2 MB zip Distributed via mod.io + --- not a container + ------------------------------------------------------------------------------- + +**14.3 Kubernetes Manifests** + +Each service gets its own directory under k8s/. The cluster has no PVCs +--- all state lives in managed PostgreSQL instances (one per service) +and S3-compatible object storage. Config and secrets are injected via +ConfigMaps and Secrets respectively; nothing sensitive is baked into an +image. + +**14.3.1 campaign-service Deployment** + +> \# k8s/campaign-service/deployment.yaml +> +> apiVersion: apps/v1 +> +> kind: Deployment +> +> metadata: +> +> name: campaign-service +> +> spec: +> +> replicas: 2 +> +> template: +> +> spec: +> +> containers: +> +> \- name: campaign-service +> +> image: ghcr.io/yourorg/campaign-service:latest +> +> ports: +> +> \- containerPort: 8080 +> +> envFrom: +> +> \- configMapRef: +> +> name: campaign-config +> +> \- secretRef: +> +> name: campaign-secrets +> +> readinessProbe: +> +> httpGet: { path: /health, port: 8080 } +> +> resources: +> +> requests: { cpu: 100m, memory: 64Mi } +> +> limits: { cpu: 500m, memory: 256Mi } + +The content-service Deployment is structured identically. Both services +expose a /health endpoint that checks database connectivity. + +**14.3.2 ConfigMaps and Secrets** + + --------------------------------------------------------------------------- + **Key** **Kind** **Used by** + ---------------------- ------------ --------------------------------------- + DATABASE_URL Secret campaign-service --- Postgres + connection string + + CONTENT_DATABASE_URL Secret content-service --- Postgres connection + string + + JWT_PRIVATE_KEY Secret campaign-service --- issues tokens + + JWT_PUBLIC_KEY Secret Both services --- verifies tokens + + S3_BUCKET / Secret campaign-service --- portrait uploads + S3_ENDPOINT / S3_KEY + + CONTENT_SERVICE_URL ConfigMap campaign-service --- internal URL for + content calls + + CORS_ORIGINS ConfigMap Both services --- allowlisted web app + origins + + VITE_API_BASE_URL ConfigMap campaign-web, content-editor --- + injected at build time + --------------------------------------------------------------------------- + +**14.3.3 Services and Ingress** + +> \# Internal cluster services (ClusterIP --- not externally reachable) +> +> campaign-service:8080 → campaign pods +> +> content-service:8080 → content pods +> +> \# Ingress (TLS terminated at ingress controller) +> +> api.campaign-manager.example.com → campaign-service +> +> content.campaign-manager.example.com → content-service (public read) +> +> \+ content-editor (nginx, static) +> +> campaign-manager.example.com → campaign-web (nginx, static) + +The campaign-web and content-editor nginx pods are fronted by the same +ingress controller. Static asset paths (/assets/\*) have a separate +ingress rule that sets long cache headers at the ingress level in +addition to what nginx returns. + +**14.4 Database Migrations** + +Each Rust service owns its own migrations using sqlx-migrate. Migrations +run automatically at startup before the HTTP server binds, guaranteeing +the schema is always in sync with the binary. Rolling back is done by +deploying the previous image version; down-migrations are written for +every change. + +> \# Embedded in each binary --- no separate init container required +> +> sqlx::migrate!(\'./migrations\').run(&pool).await?; + +**14.5 CI/CD Pipelines** + +GitHub Actions runs three pipelines on pull request and on merge to +main. + +**backend.yml --- Rust workspace** + +- cargo fmt \--check and cargo clippy \--deny warnings + +- cargo test \--workspace (unit + integration tests against a test + Postgres spun up via a service container) + +- cargo build \--release for both binaries + +- docker build + push to GHCR for campaign-service and content-service + on merge to main + +- kubectl rollout restart deployment/campaign-service and + content-service in the target cluster + +**frontend.yml --- pnpm workspace** + +- pnpm install \--frozen-lockfile + +- pnpm \--filter ruleset-core test and pnpm \--filter api-client test + +- pnpm \--filter campaign-web build and pnpm \--filter content-editor + build + +- docker build + push to GHCR for campaign-web and content-editor on + merge to main + +- kubectl rollout restart deployment/campaign-web and content-editor + on merge to main + +**symbiote.yml --- mod.io release** + +- pnpm \--filter symbiote build (outputs to apps/symbiote/dist/) + +- zip -r symbiote-v\$VERSION.zip manifest.json dist/ + +- Upload to mod.io via the mod.io REST API using a repository secret + MOD_IO_API_KEY + +- Triggered only on a version tag push (v\*.\*.\*) --- not on every + merge to main + +- GitHub Release created automatically with the zip attached as an + asset + +**14.6 Local Development** + +A docker-compose.yml at the repo root spins up the two Postgres +databases and a MinIO instance (S3-compatible) for local development. +The Rust services and Svelte apps run natively on the host, +hot-reloading against the containerised datastores. + +> services: +> +> campaign-db: +> +> image: postgres:16-alpine +> +> environment: { POSTGRES_DB: campaign, POSTGRES_PASSWORD: dev } +> +> ports: \[\'5432:5432\'\] +> +> content-db: +> +> image: postgres:16-alpine +> +> environment: { POSTGRES_DB: content, POSTGRES_PASSWORD: dev } +> +> ports: \[\'5433:5432\'\] +> +> minio: +> +> image: minio/minio +> +> command: server /data +> +> ports: \[\'9000:9000\', \'9001:9001\'\] + +The Symbiote is developed by pointing TaleSpire at the local Symbiote +directory (apps/symbiote/) and running pnpm \--filter symbiote dev, +which writes changes into that directory on every save, triggering +TaleSpire\'s live-reload. + +**15. Caching Strategy** + +Caching is handled by two isolated Valkey instances --- one per backend +service --- plus HTTP cache headers for static frontend assets. Valkey +is a fully open-source Redis-compatible store; the Rust services connect +using the redis-rs crate. Each service also maintains a small in-process +L1 cache (moka) in front of Valkey for the hottest reads, giving a +two-tier cache hierarchy with no cross-service coupling. + +**15.1 Two-Tier Cache Architecture** + + ------------------------------------------------------------------------- + **Tier** **Technology** **Scope** **Typical TTL** + ------------ ------------------ ------------------ ---------------------- + L1 moka (in-process) Single pod, 5--30 seconds + per-request dedup + + L2 Valkey (shared All pods of one Minutes to hours, or + across pods) service until invalidated + + L3 PostgreSQL Source of truth N/A + ------------------------------------------------------------------------- + +On a cache read: L1 is checked first. On L1 miss, L2 (Valkey) is +checked. On L2 miss, the database is queried and the result is written +back to both L1 and L2. On a cache write (active invalidation): the +database is updated first, then the Valkey key is deleted, then the L1 +entry is evicted. The next read from any pod repopulates both tiers from +the fresh database value. + +**15.2 Campaign Service Valkey** + +The campaign-service Valkey instance handles three concerns: refresh +token storage, character sheet caching, and JWT public-key caching for +inbound content-service tokens. + +**15.2.1 Refresh Token Store** + +Refresh tokens are stored in Valkey with a TTL matching their validity +window (30 days). On logout or token rotation, the old token is deleted +immediately. This is the one case where Valkey is the primary store +rather than a cache --- the token record does not exist in PostgreSQL. + +> \# Key pattern +> +> refresh_token:{token_hash} → { user_id, issued_at } TTL: 30d +> +> \# On login: SET refresh_token:{hash} {payload} EX 2592000 +> +> \# On refresh: DEL refresh_token:{old_hash} +> +> \# SET refresh_token:{new_hash} {payload} EX 2592000 +> +> \# On logout: DEL refresh_token:{hash} + +Because refresh tokens are security-critical, this Valkey instance is +configured with appendonly yes (AOF persistence) so tokens survive a pod +restart. This is the one exception to the no-PVC rule --- the +campaign-service Valkey gets a small PVC (1 Gi) for AOF data. + +**15.2.2 Character Sheet Cache** + +Character sheets are read frequently during sessions (Symbiote polls on +reconnect; web app loads on navigation) but written infrequently. Active +invalidation on write ensures any pod always serves the current sheet. + +> \# Key pattern +> +> character:{character_id} → JSON sheet payload TTL: 1h (safety +> fallback) +> +> \# On GET /characters/:id: check L1 → check Valkey → query DB → +> backfill +> +> \# On PUT/PATCH /characters/:id: write DB → DEL character:{id} → evict +> L1 + +The 1-hour TTL is a safety fallback only --- active invalidation means +the key is deleted on every write, so stale reads should not occur in +practice. The TTL catches any edge case where invalidation fails (e.g. a +pod crash mid-write). + +**15.2.3 Group Membership Cache** + +Permission checks on group endpoints (is this user a member? are they +the DM?) are performed on nearly every request. Caching the membership +record avoids a database round-trip on each check. + +> \# Key pattern +> +> group_member:{group_id}:{user_id} → { role } TTL: 10m +> +> \# Invalidated on: member add, member remove, DM transfer + +**15.3 Content Service Valkey** + +The content-service Valkey instance caches content entries, dependency +graphs, and ruleset metadata. Content is read extremely frequently +(every picker interaction, every character save validation) but changes +only when a maintainer approves or edits an entry. Active invalidation +on write makes this straightforward. + +**15.3.1 Entry Cache** + +> \# Key pattern +> +> entry:{entry_id} → full entry JSON TTL: 24h +> +> entry_list:{ruleset_id}:{type} → list of entry IDs TTL: 10m +> +> entry_search:{ruleset_id}:{hash_of_query} → result list TTL: 5m + +Individual entry records are cached with a long TTL because active +invalidation handles freshness. Search result lists use a shorter TTL +since they are harder to invalidate precisely --- a new approval could +change any search result. + +**15.3.2 Active Invalidation on Approval** + +When a maintainer transitions an entry to approved, or edits an existing +approved entry, the content-service invalidates aggressively: + +> // In Rust --- on entry status change or edit +> +> valkey.del(format!(\"entry:{}\", entry_id)).await?; +> +> valkey.del(format!(\"entry_list:{}:{}\", ruleset_id, +> entry_type)).await?; +> +> // Flush all search keys for this ruleset (pattern delete) +> +> let keys: Vec\ = valkey.keys( +> +> format!(\"entry_search:{}:\*\", ruleset_id) +> +> ).await?; +> +> if !keys.is_empty() { +> +> valkey.del(keys).await?; +> +> } + +Pattern deletes (KEYS) are used here because the search key space is +small and writes are infrequent. For a large-scale deployment this could +be replaced with a dedicated search invalidation topic, but for v1 the +pattern scan is acceptable. + +**15.3.3 Dependency Graph Cache** + +Dependency graphs are the most expensive reads --- resolving a deep +prerequisite chain requires recursive joins. The resolved graph for each +entry is cached as a single serialised JSON blob. + +> \# Key pattern +> +> dep_graph:{entry_id} → resolved graph JSON TTL: 24h +> +> \# Invalidated when: the entry itself changes, OR any entry in its +> +> \# dependency graph changes. On any dep edge add/remove: +> +> \# DEL dep_graph:{from_entry_id} +> +> \# DEL dep_graph:{to_entry_id} (graph may be traversed from either +> end) + +**15.3.4 Ruleset Metadata Cache** + +> ruleset:{ruleset_id} → ruleset metadata JSON TTL: 1h +> +> ruleset_list → list of all rulesets TTL: 1h + +Ruleset records change rarely (new rulesets are infrequent). A 1-hour +TTL with active invalidation on any ruleset write is sufficient. + +**15.4 Key Naming Conventions** + +Both services follow a consistent key naming scheme to make debugging +and monitoring straightforward: + + ------------------------------------------------------------------------------------- + **Pattern** **Service** **Notes** + ---------------------------------------- ------------- ------------------------------ + refresh_token:{hash} Campaign Security-critical; AOF + persisted + + character:{id} Campaign Active invalidation on every + write + + group_member:{group_id}:{user_id} Campaign Short TTL; invalidated on + membership change + + entry:{id} Content Active invalidation on + approval/edit + + entry_list:{ruleset_id}:{type} Content Active invalidation on any + entry change in ruleset + + entry_search:{ruleset_id}:{query_hash} Content Short TTL; pattern-deleted on + ruleset changes + + dep_graph:{id} Content Active invalidation on dep + edge changes + + ruleset:{id} / ruleset_list Content TTL-based; active invalidation + on ruleset write + ------------------------------------------------------------------------------------- + +**15.5 Kubernetes Deployment** + +Each Valkey instance runs as a single-replica StatefulSet with a PVC. +The campaign-service Valkey requires AOF persistence for refresh tokens; +the content-service Valkey uses RDB snapshots only (content is fully +recoverable from the database on a cold start). + +> \# k8s/campaign-valkey/statefulset.yaml (abbreviated) +> +> kind: StatefulSet +> +> metadata: +> +> name: campaign-valkey +> +> spec: +> +> replicas: 1 +> +> template: +> +> spec: +> +> containers: +> +> \- name: valkey +> +> image: valkey/valkey:7-alpine +> +> args: \[\"\--appendonly\", \"yes\"\] \# AOF for campaign-valkey +> +> volumeMounts: +> +> \- name: data +> +> mountPath: /data +> +> volumeClaimTemplates: +> +> \- metadata: +> +> name: data +> +> spec: +> +> accessModes: \[ReadWriteOnce\] +> +> resources: +> +> requests: +> +> storage: 1Gi + +The content-service Valkey StatefulSet is identical except \--appendonly +yes is omitted. Its PVC is 512 Mi --- content cache is warm within +minutes of a cold start from the database. + + -------------------------------------------------------------------------------------------- + **Instance** **Persistence** **PVC** **AOF** **RDB** **Used by** + ------------------- ----------------- ------------ ----------- ---------- ------------------ + campaign-valkey Required (tokens) 1 Gi Yes Yes campaign-service + + content-valkey Warm-up only 512 Mi No Yes content-service + -------------------------------------------------------------------------------------------- + +**15.6 Static Asset Caching (Frontend)** + +The two nginx containers (campaign-web, content-editor) serve Svelte +build output where all JS/CSS filenames include a content hash (e.g. +main.a3f92c.js). This enables aggressive CDN caching with safe +long-lived headers. + + ------------------------------------------------------------------------- + **Path pattern** **Cache-Control** **Rationale** + ------------------ ------------------- ---------------------------------- + /assets/\* max-age=31536000, Content-hashed filenames; safe to + (hashed) immutable cache for 1 year + + /index.html no-store Must always be fresh so new asset + hashes are discovered + + /manifest.json no-store TaleSpire re-reads this to detect + (Symbiote) updates + + API responses no-store Dynamic data; never cached at CDN + (/v1/\*) or browser + ------------------------------------------------------------------------- + +A CDN (e.g. Cloudflare) can be placed in front of both nginx +deployments. Because index.html is no-store, a new deployment is picked +up by all clients on their next page load without a cache purge step. + +**15.7 Cache Miss Behaviour & Resilience** + +Both services are designed to function correctly with Valkey completely +unavailable --- degraded performance but no outage. Every cache read is +wrapped in a fallback to the database: + +> // Rust pseudocode --- applies to both services +> +> async fn get_entry(id: Uuid, valkey: &Valkey, db: &PgPool) -\> +> Result\ { +> +> // L1: in-process moka cache +> +> if let Some(entry) = L1_CACHE.get(&id) { return Ok(entry); } +> +> // L2: Valkey +> +> match valkey.get::\(&format!(\"entry:{id}\")).await { +> +> Ok(Some(entry)) =\> { L1_CACHE.insert(id, entry.clone()); return +> Ok(entry); } +> +> Ok(None) =\> {} // cache miss --- fall through +> +> Err(\_) =\> {} // Valkey unavailable --- fall through +> +> } +> +> // L3: database +> +> let entry = db.query_entry(id).await?; +> +> let \_ = valkey.set_ex(&format!(\"entry:{id}\"), &entry, 86400).await; +> +> L1_CACHE.insert(id, entry.clone()); +> +> Ok(entry) +> +> } + +Valkey errors are logged as warnings but never propagated as request +errors. A Valkey outage increases database load but does not cause +user-facing failures. + +**16. Implementation Plan** + +This plan is optimised for a solo developer targeting the fastest path +to something playable at the table. Each phase ends with a concrete, +usable milestone. Later phases are intentionally deferred until the +earlier ones are proven --- complexity is introduced only when it is +needed. + +Assumed starting point: Kubernetes cluster available; Rust and Svelte +are new. The plan accounts for a learning curve on both. + +**16.1 Phase Overview** + + -------------------------------------------------------------------------- + **\#** **Phase** **Milestone** **Defers** + -------- ---------------- ------------------------- ---------------------- + 0 Foundations Monorepo builds locally; Everything else + CI runs; DB migrations + apply + + 1 Vertical Slice One player can log in, Groups, multi-user, + see a DSA5e sheet, roll web app, content + dice in TaleSpire service, Valkey + + 2 Groups & DM creates group, invites Web app, content + Sessions players, all see each service, Valkey, + other\'s sheets campaign journal + + 3 Campaign Web App Characters created and Content service, + edited in browser; JSON Valkey, pickers, + import works auto-calc + + 4 Caching Valkey added to both Content service + services; character + sheets and tokens cached + + 5 Content Service DSA5e entries in the Community + content DB; pickers and contributions, + validation live in editor maintainer roles + + 6 Polish & Campaign journal, offline --- + Community mode, mod.io publish, + community content + submissions + -------------------------------------------------------------------------- + +**16.2 Phase 0 --- Foundations** + +Goal: a working monorepo skeleton where both Rust services compile, both +Svelte apps build, CI passes, and database migrations apply cleanly. No +features yet --- this phase is about eliminating all infrastructure +friction before writing any product code. + +**Tasks** + +- Initialise Cargo workspace with campaign-service, content-service, + and common crates. Add sqlx, axum, tokio, and serde as dependencies. + Confirm cargo build \--release works. + +- Initialise pnpm workspace with packages/ruleset-core, + packages/ui-components, packages/api-client and apps/symbiote, + apps/campaign-web, apps/content-editor. Confirm pnpm build runs + across all. + +- Write the docker-compose.yml with campaign-db (port 5432), + content-db (port 5433), and MinIO (port 9000). Confirm docker + compose up starts cleanly. + +- Write the first sqlx migration for campaign-service (users table + only). Confirm sqlx migrate run applies it against the local DB. + +- Set up GitHub Actions: backend.yml (fmt + clippy + test), + frontend.yml (build check). Confirm both pass on an empty push. + +- Write Dockerfiles for all four images. Confirm docker build succeeds + locally for each. + +- Deploy a placeholder health-check endpoint (GET /health → 200) for + each Rust service to the k8s cluster. Confirm kubectl get pods shows + them running. + +**Acceptance Criteria** + +- cargo test \--workspace passes with zero warnings + +- pnpm build succeeds for all three apps + +- Both services reachable at their k8s ingress URLs returning 200 on + /health + +- CI pipeline is green on main + +**Rust Learning Focus for This Phase** + +- Understand the Cargo workspace model --- crates, dependencies, + feature flags + +- Get comfortable with axum\'s Router and basic handler signatures + +- Understand sqlx\'s compile-time query checking and how to run + migrations + +**16.3 Phase 1 --- Vertical Slice** + +Goal: one player can register, log in, open the Symbiote in TaleSpire, +see their DSA5e character sheet, and click a button to put dice in their +hand. This is the core loop. Everything else in the project exists to +support and extend this. + +**Backend (campaign-service)** + +- Add users table migration. Implement POST /auth/register and POST + /auth/login returning a JWT pair. Store refresh tokens in Postgres + for now (Valkey comes in Phase 4). + +- Add characters table migration with sheet_data JSONB. Implement + GET/POST/PUT /characters endpoints. No group logic yet --- + characters are owned by a user and that is enough. + +- Implement POST /auth/refresh and POST /auth/logout (revoke refresh + token row in DB). + +- Write integration tests for the full auth flow using a test + database. + +**Ruleset Core (packages/ruleset-core)** + +- Define the TypeScript Ruleset interface: id, name, defaultSheet(), + validate(), getStatBlocks(), getDiceActions(), renderSheet(). + +- Implement the dsa5e plugin: attributes (MU, KL, IN, CH, FF, GE, KO, + KK), derived values (LeP, AsP, KaP), talents, and a minimal set of + combat dice actions. Keep it small --- a playable sheet, not a + complete implementation. + +- Write unit tests for the dsa5e plugin\'s validate() and + getDiceActions() against a sample sheet fixture. + +**Symbiote (apps/symbiote)** + +- Wire up the TS API hasInitialized event. On init, check TS.storage + for tokens. If none, show a login form. + +- Implement login: POST /auth/login via the api-client package, store + tokens in TS.storage, navigate to the sheet view. + +- Implement the character sheet view: fetch GET /characters (filter to + the user\'s first character), pass sheet_data to the dsa5e plugin\'s + renderSheet(), display the result. + +- Implement dice action buttons: each DiceAction from getDiceActions() + renders as a button that calls + TS.dice.putDiceInHand(action.diceString). + +- Register a TS dice result listener and log rolls to the console + (dice history UI comes in Phase 2). + +- Test the full loop manually in TaleSpire: register → login → see + sheet → click roll button → dice appear in hand. + +**Acceptance Criteria** + +- New user can register and log in via the Symbiote + +- DSA5e character sheet renders with correct attribute and derived + value fields + +- At least three dice action buttons work (e.g. attribute check 3d6, + melee attack, dodge) + +- Clicking a dice button puts the correct dice string in the TaleSpire + dice hand + +- Token refresh works silently --- a 15-minute access token expiry + does not log the user out + +**Svelte Learning Focus for This Phase** + +- Understand Svelte\'s reactivity model (\$: declarations, stores) + +- Get comfortable with component props and event dispatch + +- Understand how Vite handles imports across the pnpm workspace + packages + +**16.4 Phase 2 --- Groups & Sessions** + +Goal: a DM can create a group, invite players, and during a session +everyone can see each other\'s sheets and live roll history in the +Symbiote. + +**Backend (campaign-service)** + +- Add groups, group_members, and group_characters migrations. + +- Implement full groups API: POST /groups, GET /groups, GET + /groups/:id, PATCH /groups/:id, DELETE /groups/:id. + +- Implement invite flow: POST /groups/:id/invite (generates a token), + POST /groups/:id/join/:token. + +- Implement GET/POST/DELETE /groups/:id/characters for character + assignment. + +- Implement POST /groups/:id/rolls and GET /groups/:id/rolls for dice + history. + +- Add WebSocket support to axum (axum::extract::ws). Implement a group + room: authenticate via query param JWT, join room by group_id, + broadcast roll events to all connected clients in the room. + +**Symbiote** + +- Add a group selection screen after login: list groups, allow + creating or joining one. + +- On entering a group as DM: show all members\' character sheets in a + sidebar. On entering as player: show own sheet plus a compact view + of party members\' HP. + +- Connect to the WebSocket roll room on group entry. Render a live + roll history panel (character name, dice string, result, timestamp). + +- On dice result event from TS API, POST to /groups/:id/rolls and emit + via WebSocket so all clients see it in real time. + +- Handle board switch events: suppress API calls while TS signals a + board transition; reconnect WebSocket after rejoining. + +**Acceptance Criteria** + +- DM creates a group and shares an invite link; player joins + successfully + +- Both clients see the same roll history panel update live when either + player rolls + +- DM can see all character sheets; player sees own sheet plus party HP + bars + +- Group survives a board switch without losing connection or state + +**16.5 Phase 3 --- Campaign Web App** + +Goal: players can manage their characters and groups from a browser +before the session. JSON import lets players bring characters from other +tools. + +**campaign-web (apps/campaign-web)** + +- Set up SvelteKit with file-based routing. Configure the HttpOnly + cookie auth flow (login page, silent refresh on load, protected + route guard). + +- Build the group dashboard (/groups): card grid, create group button, + invite flow. + +- Build the character library (/characters): list all owned + characters, create new, delete. + +- Build the character editor (/characters/:id): full DSA5e sheet form + rendered by the dsa5e plugin\'s renderSheet(), auto-save every 30 + seconds, optimistic UI on field changes. + +- Build the JSON import flow: file picker, detect .cmchar vs generic + JSON, render field mapper for generic JSON, run validate() before + saving, show field-level errors. + +- Build the character export: GET /characters/:id → download as + .cmchar file. + +- Add portrait upload: drag-and-drop to a file input, validate MIME + + size client-side, request a presigned URL from the backend, upload + directly to MinIO/S3. + +**Backend additions** + +- Add presigned URL endpoint: POST /characters/:id/portrait-upload-url + → returns a signed S3 PUT URL valid for 5 minutes. + +- Add GET /characters/:id/history endpoint returning last 10 + sheet_data versions (store versions in a character_history table --- + add the migration now). + +- Configure CORS to allow the campaign-web origin in addition to + localhost:8080. + +**Acceptance Criteria** + +- Player registers on the web app, creates a DSA5e character, and it + appears immediately in the Symbiote + +- .cmchar export from the web app imports cleanly back into a fresh + account + +- Generic JSON import with a custom field mapping creates a valid + character + +- Portrait upload appears on the character sheet in the Symbiote + +**16.6 Phase 4 --- Caching** + +Goal: add Valkey to both services, move refresh tokens out of Postgres, +and cache character sheets and content entries. The system should handle +a session with no database reads for hot paths. + +**Tasks** + +- Deploy campaign-valkey StatefulSet (AOF, 1 Gi PVC) and + content-valkey StatefulSet (RDB only, 512 Mi PVC) to the k8s + cluster. + +- Add redis-rs and moka as dependencies to both Rust crates. + +- campaign-service: migrate refresh token storage from Postgres to + Valkey. Add the L1/L2 read-through wrapper for GET /characters/:id + and group membership checks. Add active invalidation DEL calls to + PUT/PATCH /characters/:id and all group membership mutation + endpoints. + +- content-service: add L1/L2 read-through for GET + /rulesets/:id/entries and GET /entries/:id. Add active invalidation + on all entry write endpoints. + +- Write a load test (using k6 or oha) against GET /characters/:id with + a warm cache. Target: p99 \< 10 ms. + +- Confirm Valkey-down behaviour: stop the Valkey pod, verify requests + still succeed (slower, DB fallback), restart Valkey, verify cache + warms back up. + +**Acceptance Criteria** + +- p99 latency on cached character reads under 10 ms + +- Valkey pod restart causes no user-facing errors + +- Refresh token revocation (logout) takes effect immediately on all + pods + +**16.7 Phase 5 --- Content Service** + +Goal: DSA5e reference data lives in the content DB. The character editor +uses searchable pickers for talents, spells, and items. Saves are +validated against the dependency graph. + +**Tasks** + +- Set up the content-service binary with its own Postgres DB and + Valkey. Write the baseline migrations: rulesets, content_entries, + content_dependencies, ruleset_maintainers. + +- Seed the DSA5e ruleset with a complete set of talents, a + representative set of spells and special abilities, and common + items. Write a seed script that reads from a JSON fixture file so it + is repeatable. + +- Implement the full Content Service API (§13.5): public read + endpoints, authenticated write endpoints, status transition + endpoint. + +- Implement POST /entries/:id/validate-sheet --- walk the dependency + graph for all entry IDs referenced in the sheet, return a structured + list of violations. + +- campaign-service: add the internal service-to-service HTTP client + (reqwest). Add the in-process DashMap cache for content responses. + Wire picker search and pre-save validation into PUT /characters/:id. + +- campaign-web: replace plain text fields in the character editor with + searchable pickers for talents, spells, and items. Render validation + errors returned by the backend. + +- Deploy content-service to k8s. Configure the cluster-internal DNS so + campaign-service can reach content-service:8080 without going + through the public ingress. + +**Acceptance Criteria** + +- Talent picker in the character editor searches and returns correct + DSA5e results + +- Selecting a special ability with an unmet prerequisite shows a + validation error + +- Content service is not reachable from outside the cluster (ClusterIP + only for write endpoints) + +**16.8 Phase 6 --- Polish & Community** + +Goal: the project is ready for public mod.io release and community +content contributions. + +**Tasks** + +- Campaign journal: add journal_entries table migration to + campaign-service. Build the journal UI in both the Symbiote (read + + write during session) and campaign-web (full editor between + sessions). + +- Offline mode: implement TS.storage sheet caching in the Symbiote. + Detect network failures and show a read-only cached sheet with an + offline banner. Queue writes and flush on reconnect. + +- Content editor web app (apps/content-editor): build the full editing + UI --- entry browser, submission form, review queue, dependency + graph visualisation (SVG force-directed layout using d3 or similar). + +- Community contributions: open up the content service write endpoints + to registered users (draft submissions). Recruit DSA5e maintainers. + +- mod.io release: package the Symbiote, write a good description and + screenshots, upload via the mod.io API. Set up the symbiote.yml CI + pipeline for automated releases on version tags. + +- Ruleset marketplace groundwork: add a ruleset submission flow for + community-created rulesets. Define the review process. + +**Acceptance Criteria** + +- Symbiote is listed and installable from the TaleSpire in-game mod + browser + +- Offline sheet is readable after disabling the network mid-session + +- A community member can submit a DSA5e talent entry and a maintainer + can approve it through the content editor UI + +**16.9 Recommended Learning Path** + +As a solo developer new to Rust and Svelte, the following sequence will +reduce frustration and false starts: + +**Before Phase 0** + +- Rust: work through chapters 1--10 of The Rust Book (ownership, + borrowing, structs, enums, error handling). Do not skip chapter 9 + (error handling) --- it is essential for writing axum handlers. + +- Svelte: complete the official Svelte tutorial (svelte.dev/tutorial). + Focus on reactivity, stores, and component composition. One + afternoon is enough. + +- axum: read the axum examples on GitHub (hello world, JWT auth, + WebSocket). The tokio async model will feel unfamiliar at first --- + lean on .await and let the compiler guide you. + +- sqlx: read the sqlx README and try the query!() macro against a + local Postgres. The compile-time checking is its main value --- + understand why it requires a live DB at compile time (or use the + offline mode with sqlx prepare). + +**During Phase 1** + +- Use thiserror for defining error types and implement axum\'s + IntoResponse for them. This pattern will carry through the entire + codebase. + +- Write integration tests from the start --- axum has excellent test + utilities (TestClient). Tests will catch borrow checker issues + before they become production bugs. + +- For Svelte, start with a flat component structure. Introduce stores + only when you need shared state across components that are not + parent/child. + +**General** + +- Resist the urge to add complexity early. The generic ruleset + interface, the content service, and Valkey all exist in the design + but should not be built until the phase that introduces them. + +- Commit at the end of every working session. Use conventional commits + (feat:, fix:, chore:) --- the CI pipeline and future changelog + generation will benefit. + +- When stuck on the borrow checker: step back, simplify, and re-read + the relevant Rust Book chapter. Fighting the borrow checker usually + means the design needs adjusting, not the syntax. + +**16.10 Estimated Phase Effort (Solo)** + +These estimates assume part-time development (evenings and weekends) and +a learning curve on Rust and Svelte. They are intentionally +conservative. + + ---------------------------------------------------------------------------- + **\#** **Phase** **Est. **Complexity** **Risk** + Weeks** + -------- ---------------- ------------- ---------------- ------------------- + 0 Foundations 1--2 Low Low --- mostly + tooling and config + + 1 Vertical Slice 4--6 High High --- Rust + + Svelte + TS API all + new at once + + 2 Groups & 3--4 Medium Medium --- + Sessions WebSocket in Rust + is well-documented + + 3 Campaign Web App 3--5 Medium Low --- Svelte will + be familiar by now + + 4 Caching 1--2 Low Low --- + well-defined scope, + good Rust crate + support + + 5 Content Service 4--6 High Medium --- + dependency graph + logic is the hard + part + + 6 Polish & 3--5 Medium Low --- features + Community are additive, no + structural changes + ---------------------------------------------------------------------------- + +Total estimated range: 19--30 weeks of part-time work to a full public +release. Phases 0--2 (playable at the table) can be reached in roughly +8--12 weeks. + +**12. Resolved Decisions & Planned Features** + +**12.1 Resolved** + +- DM character visibility: DMs can read full sheet_data for all + characters in their group. The GET /groups/:id/characters endpoint + returns complete sheet objects to DM-role callers and owner-only + data to player-role callers. + +- Multi-DM: not in scope for v1. The data model enforces exactly one + dm_user_id per group. This can be revisited post-launch. + +**12.2 Planned for v2** + +- Campaign Journal: a group-level shared notes space. Stored as + structured Markdown in a new journal_entries table (group_id, + author_user_id, content, created_at). Visible to all group members; + editable by the author and the DM. Surfaced in both the web app and + the Symbiote. + +- Offline Support: TS.storage caches the last-fetched sheet_data per + character. The Symbiote detects network failure and renders a + read-only cached sheet with a banner indicating offline status. + Writes are queued and flushed on reconnect. + +- Mod.io publishing: once stable, package and submit to mod.io so + players can install via the TaleSpire in-game browser. + +- Ruleset marketplace: a community registry where plugin authors can + publish new game systems independently of the core project. + +- Additional rulesets: D&D 5e, Pathfinder 2e, and Call of Cthulhu are + natural follow-ons after the DSA5e baseline proves the plugin + contract. + +**13. Content Database** + +The content database is a second PostgreSQL instance connected to the +same Axum service via a dedicated sqlx connection pool. It is entirely +separate from the campaign database: no foreign keys cross between them, +and the two pools are independently configured, migrated, and backed up. +The campaign database owns users, groups, and characters. The content +database owns every piece of game-system knowledge --- rulebooks, rules, +items, monsters, spells, talents, and the dependency graph that links +them together. + +This separation means the content database can be iterated, exported, or +replaced without touching campaign data, and it can eventually serve +multiple front-ends (the character editor, a standalone compendium site, +a Symbiote reference panel) with identical data. + +**13.1 Two-Pool Architecture** + +At startup, the Axum service initialises two sqlx PgPool instances from +separate connection strings --- one for each database. Route handlers +that need content data receive the content pool via Axum\'s dependency +injection (State extractor). No handler ever touches both pools in the +same transaction; cross-database consistency is managed at the +application layer, not the database layer. + +> // axum State setup (simplified) +> +> struct AppState { +> +> campaign_db: PgPool, // DATABASE_URL +> +> content_db: PgPool, // CONTENT_DATABASE_URL +> +> } + +Both databases run as separate Kubernetes StatefulSets backed by a +managed Postgres service (or separate PVCs if self-hosting). No PVC is +needed on the Axum pods themselves --- they remain fully stateless. + +**13.2 Schema** + +**13.2.1 rulesets** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + id VARCHAR(100) Yes e.g. \'dsa5e\', + (PK) \'dnd5e\'. Matches + ruleset_id in the + campaign DB. + + name VARCHAR(150) Yes Display name, e.g. + \'Das Schwarze Auge + 5. Edition\' + + version VARCHAR(50) Yes Content schema + version, semver + + description TEXT No + + published BOOLEAN Yes false = draft; only + maintainers can see + --------------------------------------------------------------------------- + +**13.2.2 ruleset_roles (contributor access)** + + ------------------------------------------------------------------------------ + **Field** **Type** **Required** **Description** + ---------------------- ------------------- -------------- -------------------- + ruleset_id VARCHAR(100) (FK) Yes Composite PK + + user_id UUID (FK → Yes Composite PK. + campaign.users) Cross-DB join done + in application code. + + role ENUM(contributor, Yes contributor = submit + maintainer) only; maintainer = + approve + publish + + granted_at TIMESTAMPTZ Yes + + granted_by UUID Yes user_id of the + maintainer who + granted this role + ------------------------------------------------------------------------------ + +**13.2.3 content_entries** + + ------------------------------------------------------------------------------ + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- ----------------------- + id UUID (PK) Yes + + ruleset_id VARCHAR(100) (FK Yes + → rulesets) + + entry_type VARCHAR(80) Yes e.g. \'talent\', + \'combat_technique\', + \'spell\', \'item\', + \'monster\', \'rule\', + \'special_ability\' + + slug VARCHAR(200) Yes URL-safe identifier, + unique per + ruleset+type. e.g. + \'acrobatics\' + + name VARCHAR(200) Yes Display name + + data JSONB Yes Type-specific payload + validated by the + ruleset plugin schema + + source_ref VARCHAR(200) No Rulebook + page, e.g. + \'WdS p.42\' + + status ENUM(draft, Yes Drives the contribution + review, workflow + published, + rejected) + + created_by UUID Yes user_id of the original + author + + created_at TIMESTAMPTZ Yes + + updated_at TIMESTAMPTZ Yes + ------------------------------------------------------------------------------ + +**13.2.4 content_revisions (edit history)** + + ----------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ------------------ -------------- -------------------- + id UUID (PK) Yes + + entry_id UUID (FK → Yes + content_entries) + + data_snapshot JSONB Yes Full copy of data at + this point in time + + changed_by UUID Yes user_id + + change_note TEXT No Optional commit + message from the + editor + + created_at TIMESTAMPTZ Yes + ----------------------------------------------------------------------------- + +**13.2.5 content_dependencies (graph edges)** + + ----------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ------------------ -------------- -------------------- + from_entry_id UUID (FK → Yes The entry that + content_entries) requires something + + to_entry_id UUID (FK → Yes The entry that is + content_entries) required + + dep_type ENUM(requires, Yes requires = hard + enhances, prerequisite; + conflicts) enhances = optional + synergy; conflicts = + mutually exclusive + + condition TEXT No Human-readable + condition + description, e.g. + \'FF 13 or higher\' + ----------------------------------------------------------------------------- + +**13.2.6 rulebook_sections (structured lore/rules text)** + + --------------------------------------------------------------------------- + **Field** **Type** **Required** **Description** + ---------------------- ---------------- -------------- -------------------- + id UUID (PK) Yes + + ruleset_id VARCHAR(100) (FK Yes + → rulesets) + + title VARCHAR(300) Yes Section heading + + body TEXT Yes Markdown-formatted + rules text + + parent_section_id UUID (FK, No Enables nested + nullable) chapter structure + + order_index INTEGER Yes Sort order within + parent + + status ENUM(draft, Yes + published) + + created_by UUID Yes + + created_at TIMESTAMPTZ Yes + --------------------------------------------------------------------------- + +**13.3 Contribution Roles** + +Two roles govern who can do what to a given ruleset\'s content. Roles +are per-ruleset --- a user can be a maintainer for DSA5e and merely a +contributor for a community homebrew system. + + ------------------------------------------------------------------------ + **Role** **Assigned by** **Capabilities** + --------------- ------------------ ------------------------------------- + Any registered --- Submit new entries or edits (status = + user draft → review) + + contributor Maintainer Submit entries; view all drafts for + the ruleset; cannot approve or + publish + + maintainer Admin or other Approve, reject, or publish any + maintainer entry; edit without review; manage + contributor list + + admin System Full access to all rulesets; can + create rulesets, promote maintainers + ------------------------------------------------------------------------ + +**13.4 Contribution & Review Workflow** + +- Any registered user submits a new entry or edit. The entry lands in + status = \'review\'. The submitter cannot publish it themselves. + +- A maintainer for that ruleset reviews the entry in the content + editor. They can approve (→ published), reject (→ rejected with a + note), or request changes (entry returns to draft with a comment + thread). + +- Maintainers can also edit and publish directly without the review + step, for trusted bulk imports or corrections. + +- All state transitions are recorded in content_revisions so the full + editorial history is auditable. + +- Published entries are immediately available to the character editor + and pickers. Drafts and review-queue entries are only visible to + contributors and maintainers of that ruleset. + +**13.5 Integration with the Character Editor** + +The character editor in the web app (and Symbiote) integrates the +content database through three distinct mechanisms, all driven by the +active ruleset plugin. + +**13.5.1 Searchable Pickers** + +When a field in the character sheet requires selecting a game entity (a +talent, weapon, spell, special ability, etc.), the editor renders a +picker component rather than a free-text input. The picker calls: + +> GET +> /content/{ruleset_id}/entries?type={entry_type}&q={search_term}&limit=20 + +Results are paginated and returned with name, slug, source_ref, and a +summary excerpt from the data blob. Selecting an entry writes only its +id and slug to sheet_data --- the full entry is always fetched fresh +from the content DB at render time, so corrections to the content +database are immediately reflected on existing characters without sheet +migration. + +**13.5.2 Auto-Calculation of Derived Stats** + +When sheet_data changes, the ruleset plugin\'s calculate() method runs +client-side and updates all derived values in memory before the sheet +re-renders. The calculation engine reads base attributes from sheet_data +and looks up any content-database entries (e.g. a talent\'s base +attribute formula) to produce final values. + +> interface Ruleset { +> +> // \... existing methods \... +> +> calculate(sheet: SheetData, entries: EntryMap): DerivedStats +> +> } + +EntryMap is a pre-fetched, client-side cache of all content entries +referenced by the current sheet, loaded once on sheet open and +invalidated on content DB version bump. This means calculations are +synchronous and instant --- no round-trip required during editing. + +**13.5.3 Rule Validation** + +Before saving, the ruleset plugin\'s validate() method checks the full +sheet against both the structural schema and the dependency graph. +Prerequisite checking walks the content_dependencies edges: + +- \'requires\' edges: the character must meet the condition (e.g. + attribute threshold) or already have the prerequisite entry on their + sheet + +- \'conflicts\' edges: if the character has entry A, entry B cannot be + added + +- \'enhances\' edges: informational only; shown as suggestions in the + UI but never block saving + +Validation errors are displayed inline next to the offending field, with +a link to the dependency entry in the compendium so the player +understands why the rule applies. + +**13.6 Content API Endpoints** + + ------------------------------------------------------------------------------------------------- + **Method + Path** **Auth** **Description** + ----------------------------------------------- -------------- ---------------------------------- + GET /content/:ruleset_id/entries Any user Search/list published entries. + Supports ?type=, ?q=, ?limit=, + ?offset= + + GET /content/:ruleset_id/entries/:id Any user Single entry with full data, + source_ref, and dependency list + + GET Any user Full dependency subgraph for an + /content/:ruleset_id/entries/:id/dependencies entry (BFS up to configurable + depth) + + POST /content/:ruleset_id/entries Registered Submit a new entry (status = + user review) + + PUT /content/:ruleset_id/entries/:id Contributor+ Submit an edit (status → review + unless caller is maintainer) + + POST /content/:ruleset_id/entries/:id/approve Maintainer Approve and publish an entry + + POST /content/:ruleset_id/entries/:id/reject Maintainer Reject with a reason string + + GET /content/:ruleset_id/entries/:id/revisions Contributor+ Full revision history for an entry + + GET /content/:ruleset_id/sections Any user Rulebook sections (tree structure + via parent_section_id) + + POST /content/:ruleset_id/sections Maintainer Create a new rulebook section + + PUT /content/:ruleset_id/sections/:id Maintainer Edit section body or title + + GET /content/rulesets Any user List all published rulesets (id, + name, version) + + POST /content/rulesets Admin Create a new ruleset entry + + GET /content/:ruleset_id/queue Contributor+ Review queue: all entries in + status = review for this ruleset + ------------------------------------------------------------------------------------------------- + +**13.7 Content Editor Web App** + +The content editor is a separate Svelte app +(content.campaign-manager.example.com) that shares the same auth system +and Axum backend. It is the primary tool for maintainers and +contributors to manage ruleset content. The character editor web app +embeds read-only pickers that link back to the content editor for +details. + +**13.7.1 Routes** + + ----------------------------------------------------------------------------- + **Route** **Description** + ------------------------------- --------------------------------------------- + / Ruleset selector; shows all rulesets the user + has any role in, plus public ones + + /:ruleset_id/entries Searchable, filterable entry list. Toggle + between published and review queue. + + /:ruleset_id/entries/new Entry creation form with type selector; + fields driven by ruleset plugin schema + + /:ruleset_id/entries/:id Entry detail: full data view, dependency + graph visualisation, revision history + + /:ruleset_id/entries/:id/edit Edit form with diff preview against last + published version + + /:ruleset_id/queue Maintainer review queue: one-click + approve/reject with comment + + /:ruleset_id/sections Rulebook section tree with drag-to-reorder; + Markdown editor for body + + /:ruleset_id/contributors Maintainer-only: manage contributor and + maintainer list + ----------------------------------------------------------------------------- + +**13.7.2 Dependency Graph View** + +The entry detail page renders an interactive force-directed graph of the +selected entry\'s dependency neighbourhood (requires / enhances / +conflicts edges up to 2 hops). This gives maintainers and contributors +an at-a-glance view of how tightly coupled an entry is before editing +it, and helps players browsing the compendium understand build paths. + +**13.7.3 Schema-Driven Forms** + +Entry creation and edit forms are generated from JSON Schema definitions +exported by the ruleset plugin (one schema per entry_type). This means +the content editor never needs to know DSA5e-specific field names --- it +reads the schema, renders the appropriate inputs (numeric, text, enum +dropdown, multi-select for dependencies), and validates client-side +before submitting. Adding a new entry type to a ruleset only requires +updating the plugin schema; the editor UI adapts automatically. + +**13.8 Kubernetes & Storage Notes** + +The content database runs as a second StatefulSet (or managed Postgres +instance) alongside the campaign database. Both are accessed by the same +stateless Axum pods --- no PVC on the application tier. Object storage +(S3-compatible) is used for any binary assets referenced by content +entries (token images, item artwork) using the same presigned-URL +pattern as character portraits. + +- Two separate DATABASE_URL / CONTENT_DATABASE_URL environment + variables injected as Kubernetes Secrets + +- sqlx migrations for each database live in separate + /migrations/campaign and /migrations/content directories and are run + independently at startup + +- Read replicas can be added per-database independently; the content + DB is read-heavy and benefits from a replica for picker queries + during active sessions + +- Backup schedules can differ: campaign DB (user data) backed up + continuously; content DB (curated reference data) backed up daily + +**14. Resolved Decisions & Planned Features** + +**14.1 Resolved** + +- DM character visibility: DMs can read full sheet_data for all + characters in their group. The GET /groups/:id/characters endpoint + returns complete sheet objects to DM-role callers and owner-only + data to player-role callers. + +- Multi-DM: not in scope for v1. The data model enforces exactly one + dm_user_id per group. This can be revisited post-launch. + +**14.2 Planned for v2** + +- Campaign Journal: a group-level shared notes space. Stored as + structured Markdown in a new journal_entries table (group_id, + author_user_id, content, created_at). Visible to all group members; + editable by the author and the DM. Surfaced in both the web app and + the Symbiote. + +- Offline Support: TS.storage caches the last-fetched sheet_data per + character. The Symbiote detects network failure and renders a + read-only cached sheet with a banner indicating offline status. + Writes are queued and flushed on reconnect. + +- Mod.io publishing: once stable, package and submit to mod.io so + players can install via the TaleSpire in-game browser. + +- Ruleset marketplace: a community registry where plugin authors can + publish new game systems independently of the core project. + +- Additional rulesets: D&D 5e, Pathfinder 2e, and Call of Cthulhu are + natural follow-ons after the DSA5e baseline proves the plugin + contract. + +End of Document diff --git a/package.json b/package.json new file mode 100644 index 0000000..3410bff --- /dev/null +++ b/package.json @@ -0,0 +1,14 @@ +{ + "name": "campaign-manager", + "private": true, + "pnpm": { + "onlyBuiltDependencies": ["esbuild"] + }, + "scripts": { + "dev:symbiote": "pnpm --filter symbiote dev", + "dev:web": "pnpm --filter web dev", + "build:symbiote": "pnpm --filter symbiote build", + "build:web": "pnpm --filter web build", + "build:backend": "cargo build --release" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..c66835d --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1013 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + rulesets: {} + + symbiote: + dependencies: + '@campaign-manager/rulesets': + specifier: workspace:* + version: link:../rulesets + svelte: + specifier: ^5.0.0 + version: 5.53.8 + devDependencies: + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.53.8)(vite@6.4.1) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1 + + web: + dependencies: + '@campaign-manager/rulesets': + specifier: workspace:* + version: link:../rulesets + '@sveltejs/kit': + specifier: ^2.0.0 + version: 2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1) + svelte: + specifier: ^5.0.0 + version: 5.53.8 + devDependencies: + '@sveltejs/adapter-static': + specifier: ^3.0.0 + version: 3.0.10(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1)) + '@sveltejs/vite-plugin-svelte': + specifier: ^5.0.0 + version: 5.1.1(svelte@5.53.8)(vite@6.4.1) + typescript: + specifier: ^5.0.0 + version: 5.9.3 + vite: + specifier: ^6.0.0 + version: 6.4.1 + +packages: + + '@esbuild/aix-ppc64@0.25.12': + resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.12': + resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.12': + resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.12': + resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.12': + resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.12': + resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.12': + resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.12': + resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.12': + resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.12': + resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.12': + resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.12': + resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.12': + resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.12': + resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.12': + resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.12': + resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.12': + resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.12': + resolution: {integrity: sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.12': + resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.12': + resolution: {integrity: sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.12': + resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.25.12': + resolution: {integrity: sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.25.12': + resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.12': + resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.12': + resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.12': + resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rollup/rollup-android-arm-eabi@4.59.0': + resolution: {integrity: sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.59.0': + resolution: {integrity: sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.59.0': + resolution: {integrity: sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.59.0': + resolution: {integrity: sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.59.0': + resolution: {integrity: sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.59.0': + resolution: {integrity: sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.59.0': + resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.59.0': + resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.59.0': + resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.59.0': + resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.59.0': + resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.59.0': + resolution: {integrity: sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + resolution: {integrity: sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + resolution: {integrity: sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.59.0': + resolution: {integrity: sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.59.0': + resolution: {integrity: sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@sveltejs/acorn-typescript@1.0.9': + resolution: {integrity: sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==} + peerDependencies: + acorn: ^8.9.0 + + '@sveltejs/adapter-static@3.0.10': + resolution: {integrity: sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==} + peerDependencies: + '@sveltejs/kit': ^2.0.0 + + '@sveltejs/kit@2.53.4': + resolution: {integrity: sha512-iAIPEahFgDJJyvz8g0jP08KvqnM6JvdW8YfsygZ+pMeMvyM2zssWMltcsotETvjSZ82G3VlitgDtBIvpQSZrTA==} + engines: {node: '>=18.13'} + hasBin: true + peerDependencies: + '@opentelemetry/api': ^1.0.0 + '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0 + svelte: ^4.0.0 || ^5.0.0-next.0 + typescript: ^5.3.3 + vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0 + peerDependenciesMeta: + '@opentelemetry/api': + optional: true + typescript: + optional: true + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1': + resolution: {integrity: sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + '@sveltejs/vite-plugin-svelte': ^5.0.0 + svelte: ^5.0.0 + vite: ^6.0.0 + + '@sveltejs/vite-plugin-svelte@5.1.1': + resolution: {integrity: sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22} + peerDependencies: + svelte: ^5.0.0 + vite: ^6.0.0 + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/trusted-types@2.0.7': + resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + + acorn@8.16.0: + resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} + engines: {node: '>=0.4.0'} + hasBin: true + + aria-query@5.3.1: + resolution: {integrity: sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==} + engines: {node: '>= 0.4'} + + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + + cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepmerge@4.3.1: + resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} + engines: {node: '>=0.10.0'} + + devalue@5.6.3: + resolution: {integrity: sha512-nc7XjUU/2Lb+SvEFVGcWLiKkzfw8+qHI7zn8WYXKkLMgfGSHbgCEaR6bJpev8Cm6Rmrb19Gfd/tZvGqx9is3wg==} + + esbuild@0.25.12: + resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} + engines: {node: '>=18'} + hasBin: true + + esm-env@1.2.2: + resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} + + esrap@2.2.3: + resolution: {integrity: sha512-8fOS+GIGCQZl/ZIlhl59htOlms6U8NvX6ZYgYHpRU/b6tVSh3uHkOHZikl3D4cMbYM0JlpBe+p/BkZEi8J9XIQ==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + is-reference@3.0.3: + resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} + + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + + locate-character@3.0.0: + resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.59.0: + resolution: {integrity: sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + set-cookie-parser@3.0.1: + resolution: {integrity: sha512-n7Z7dXZhJbwuAHhNzkTti6Aw9QDDjZtm3JTpTGATIdNzdQz5GuFs22w90BcvF4INfnrL5xrX3oGsuqO5Dx3A1Q==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + svelte@5.53.8: + resolution: {integrity: sha512-UD++BnEc3PUFgjin381LiMHzDjT187Fy+KsPZxvaKrYPZqR0GQ/Ha8h7GDoegIF8tFl1uogoNUejKgcRk77T2Q==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@6.4.1: + resolution: {integrity: sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitefu@1.1.2: + resolution: {integrity: sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + zimmerframe@1.1.4: + resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} + +snapshots: + + '@esbuild/aix-ppc64@0.25.12': + optional: true + + '@esbuild/android-arm64@0.25.12': + optional: true + + '@esbuild/android-arm@0.25.12': + optional: true + + '@esbuild/android-x64@0.25.12': + optional: true + + '@esbuild/darwin-arm64@0.25.12': + optional: true + + '@esbuild/darwin-x64@0.25.12': + optional: true + + '@esbuild/freebsd-arm64@0.25.12': + optional: true + + '@esbuild/freebsd-x64@0.25.12': + optional: true + + '@esbuild/linux-arm64@0.25.12': + optional: true + + '@esbuild/linux-arm@0.25.12': + optional: true + + '@esbuild/linux-ia32@0.25.12': + optional: true + + '@esbuild/linux-loong64@0.25.12': + optional: true + + '@esbuild/linux-mips64el@0.25.12': + optional: true + + '@esbuild/linux-ppc64@0.25.12': + optional: true + + '@esbuild/linux-riscv64@0.25.12': + optional: true + + '@esbuild/linux-s390x@0.25.12': + optional: true + + '@esbuild/linux-x64@0.25.12': + optional: true + + '@esbuild/netbsd-arm64@0.25.12': + optional: true + + '@esbuild/netbsd-x64@0.25.12': + optional: true + + '@esbuild/openbsd-arm64@0.25.12': + optional: true + + '@esbuild/openbsd-x64@0.25.12': + optional: true + + '@esbuild/openharmony-arm64@0.25.12': + optional: true + + '@esbuild/sunos-x64@0.25.12': + optional: true + + '@esbuild/win32-arm64@0.25.12': + optional: true + + '@esbuild/win32-ia32@0.25.12': + optional: true + + '@esbuild/win32-x64@0.25.12': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@polka/url@1.0.0-next.29': {} + + '@rollup/rollup-android-arm-eabi@4.59.0': + optional: true + + '@rollup/rollup-android-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.59.0': + optional: true + + '@rollup/rollup-darwin-x64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.59.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.59.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.59.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.59.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.59.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.59.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.59.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@sveltejs/acorn-typescript@1.0.9(acorn@8.16.0)': + dependencies: + acorn: 8.16.0 + + '@sveltejs/adapter-static@3.0.10(@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1))': + dependencies: + '@sveltejs/kit': 2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1) + + '@sveltejs/kit@2.53.4(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(typescript@5.9.3)(vite@6.4.1)': + dependencies: + '@standard-schema/spec': 1.1.0 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.53.8)(vite@6.4.1) + '@types/cookie': 0.6.0 + acorn: 8.16.0 + cookie: 0.6.0 + devalue: 5.6.3 + esm-env: 1.2.2 + kleur: 4.1.5 + magic-string: 0.30.21 + mrmime: 2.0.1 + set-cookie-parser: 3.0.1 + sirv: 3.0.2 + svelte: 5.53.8 + vite: 6.4.1 + optionalDependencies: + typescript: 5.9.3 + + '@sveltejs/vite-plugin-svelte-inspector@4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(vite@6.4.1)': + dependencies: + '@sveltejs/vite-plugin-svelte': 5.1.1(svelte@5.53.8)(vite@6.4.1) + debug: 4.4.3 + svelte: 5.53.8 + vite: 6.4.1 + transitivePeerDependencies: + - supports-color + + '@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1)': + dependencies: + '@sveltejs/vite-plugin-svelte-inspector': 4.0.1(@sveltejs/vite-plugin-svelte@5.1.1(svelte@5.53.8)(vite@6.4.1))(svelte@5.53.8)(vite@6.4.1) + debug: 4.4.3 + deepmerge: 4.3.1 + kleur: 4.1.5 + magic-string: 0.30.21 + svelte: 5.53.8 + vite: 6.4.1 + vitefu: 1.1.2(vite@6.4.1) + transitivePeerDependencies: + - supports-color + + '@types/cookie@0.6.0': {} + + '@types/estree@1.0.8': {} + + '@types/trusted-types@2.0.7': {} + + acorn@8.16.0: {} + + aria-query@5.3.1: {} + + axobject-query@4.1.0: {} + + clsx@2.1.1: {} + + cookie@0.6.0: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepmerge@4.3.1: {} + + devalue@5.6.3: {} + + esbuild@0.25.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.12 + '@esbuild/android-arm': 0.25.12 + '@esbuild/android-arm64': 0.25.12 + '@esbuild/android-x64': 0.25.12 + '@esbuild/darwin-arm64': 0.25.12 + '@esbuild/darwin-x64': 0.25.12 + '@esbuild/freebsd-arm64': 0.25.12 + '@esbuild/freebsd-x64': 0.25.12 + '@esbuild/linux-arm': 0.25.12 + '@esbuild/linux-arm64': 0.25.12 + '@esbuild/linux-ia32': 0.25.12 + '@esbuild/linux-loong64': 0.25.12 + '@esbuild/linux-mips64el': 0.25.12 + '@esbuild/linux-ppc64': 0.25.12 + '@esbuild/linux-riscv64': 0.25.12 + '@esbuild/linux-s390x': 0.25.12 + '@esbuild/linux-x64': 0.25.12 + '@esbuild/netbsd-arm64': 0.25.12 + '@esbuild/netbsd-x64': 0.25.12 + '@esbuild/openbsd-arm64': 0.25.12 + '@esbuild/openbsd-x64': 0.25.12 + '@esbuild/openharmony-arm64': 0.25.12 + '@esbuild/sunos-x64': 0.25.12 + '@esbuild/win32-arm64': 0.25.12 + '@esbuild/win32-ia32': 0.25.12 + '@esbuild/win32-x64': 0.25.12 + + esm-env@1.2.2: {} + + esrap@2.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + fsevents@2.3.3: + optional: true + + is-reference@3.0.3: + dependencies: + '@types/estree': 1.0.8 + + kleur@4.1.5: {} + + locate-character@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mrmime@2.0.1: {} + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + + postcss@8.5.8: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.59.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.59.0 + '@rollup/rollup-android-arm64': 4.59.0 + '@rollup/rollup-darwin-arm64': 4.59.0 + '@rollup/rollup-darwin-x64': 4.59.0 + '@rollup/rollup-freebsd-arm64': 4.59.0 + '@rollup/rollup-freebsd-x64': 4.59.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.59.0 + '@rollup/rollup-linux-arm-musleabihf': 4.59.0 + '@rollup/rollup-linux-arm64-gnu': 4.59.0 + '@rollup/rollup-linux-arm64-musl': 4.59.0 + '@rollup/rollup-linux-loong64-gnu': 4.59.0 + '@rollup/rollup-linux-loong64-musl': 4.59.0 + '@rollup/rollup-linux-ppc64-gnu': 4.59.0 + '@rollup/rollup-linux-ppc64-musl': 4.59.0 + '@rollup/rollup-linux-riscv64-gnu': 4.59.0 + '@rollup/rollup-linux-riscv64-musl': 4.59.0 + '@rollup/rollup-linux-s390x-gnu': 4.59.0 + '@rollup/rollup-linux-x64-gnu': 4.59.0 + '@rollup/rollup-linux-x64-musl': 4.59.0 + '@rollup/rollup-openbsd-x64': 4.59.0 + '@rollup/rollup-openharmony-arm64': 4.59.0 + '@rollup/rollup-win32-arm64-msvc': 4.59.0 + '@rollup/rollup-win32-ia32-msvc': 4.59.0 + '@rollup/rollup-win32-x64-gnu': 4.59.0 + '@rollup/rollup-win32-x64-msvc': 4.59.0 + fsevents: 2.3.3 + + set-cookie-parser@3.0.1: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + svelte@5.53.8: + dependencies: + '@jridgewell/remapping': 2.3.5 + '@jridgewell/sourcemap-codec': 1.5.5 + '@sveltejs/acorn-typescript': 1.0.9(acorn@8.16.0) + '@types/estree': 1.0.8 + '@types/trusted-types': 2.0.7 + acorn: 8.16.0 + aria-query: 5.3.1 + axobject-query: 4.1.0 + clsx: 2.1.1 + devalue: 5.6.3 + esm-env: 1.2.2 + esrap: 2.2.3 + is-reference: 3.0.3 + locate-character: 3.0.0 + magic-string: 0.30.21 + zimmerframe: 1.1.4 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + totalist@3.0.1: {} + + typescript@5.9.3: {} + + vite@6.4.1: + dependencies: + esbuild: 0.25.12 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.8 + rollup: 4.59.0 + tinyglobby: 0.2.15 + optionalDependencies: + fsevents: 2.3.3 + + vitefu@1.1.2(vite@6.4.1): + optionalDependencies: + vite: 6.4.1 + + zimmerframe@1.1.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..d7bfd7b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - 'symbiote' + - 'web' + - 'rulesets' diff --git a/rulesets/package.json b/rulesets/package.json new file mode 100644 index 0000000..f80c06f --- /dev/null +++ b/rulesets/package.json @@ -0,0 +1,7 @@ +{ + "name": "@campaign-manager/rulesets", + "private": true, + "version": "0.1.0", + "main": "src/index.ts", + "types": "src/index.ts" +} diff --git a/rulesets/src/dsa5e/index.ts b/rulesets/src/dsa5e/index.ts new file mode 100644 index 0000000..4f709ba --- /dev/null +++ b/rulesets/src/dsa5e/index.ts @@ -0,0 +1,62 @@ +import type { Ruleset, SheetData, ValidationResult, StatBlock, DiceAction, VNode } from '../types'; + +const dsa5e: Ruleset = { + id: 'dsa5e', + name: 'Das Schwarze Auge 5e', + version: '0.1.0', + + defaultSheet(): SheetData { + return { + name: '', + race: '', + culture: '', + profession: '', + attributes: { + courage: 8, + sagacity: 8, + intuition: 8, + charisma: 8, + dexterity: 8, + agility: 8, + constitution: 8, + strength: 8, + }, + derivedValues: { + lifePoints: 0, + arcaneEnergy: null, + karmaPoints: null, + initiative: 0, + dodge: 0, + woundThreshold: 0, + }, + talents: [], + combatTechniques: [], + specialAbilities: [], + spells: [], + liturgies: [], + equipment: [], + notes: '', + }; + }, + + validate(_sheet: SheetData): ValidationResult { + // TODO: implement DSA5e validation rules + return { valid: true, errors: [] }; + }, + + getStatBlocks(_sheet: SheetData): StatBlock[] { + // TODO: extract stat blocks from sheet + return []; + }, + + getDiceActions(_sheet: SheetData): DiceAction[] { + // TODO: derive dice actions from talents and combat techniques + return []; + }, + + renderSheet(_sheet: SheetData): VNode { + throw new Error('not implemented'); + }, +}; + +export default dsa5e; diff --git a/rulesets/src/generic/index.ts b/rulesets/src/generic/index.ts new file mode 100644 index 0000000..50fa088 --- /dev/null +++ b/rulesets/src/generic/index.ts @@ -0,0 +1,34 @@ +import type { Ruleset, SheetData, ValidationResult, StatBlock, DiceAction, VNode } from '../types'; + +const generic: Ruleset = { + id: 'generic', + name: 'Generic', + version: '0.1.0', + + defaultSheet(): SheetData { + return { + name: '', + hp: 0, + maxHp: 0, + notes: '', + }; + }, + + validate(_sheet: SheetData): ValidationResult { + return { valid: true, errors: [] }; + }, + + getStatBlocks(_sheet: SheetData): StatBlock[] { + return []; + }, + + getDiceActions(_sheet: SheetData): DiceAction[] { + return []; + }, + + renderSheet(_sheet: SheetData): VNode { + throw new Error('not implemented'); + }, +}; + +export default generic; diff --git a/rulesets/src/index.ts b/rulesets/src/index.ts new file mode 100644 index 0000000..480fc2c --- /dev/null +++ b/rulesets/src/index.ts @@ -0,0 +1,21 @@ +export type { + SheetData, + FieldError, + ValidationResult, + StatBlock, + DiceAction, + VNode, + Ruleset, +} from './types'; + +export { default as dsa5e } from './dsa5e/index'; +export { default as generic } from './generic/index'; + +import type { Ruleset } from './types'; +import dsa5e from './dsa5e/index'; +import generic from './generic/index'; + +export const RULESETS: Record = { + [dsa5e.id]: dsa5e, + [generic.id]: generic, +}; diff --git a/rulesets/src/types.ts b/rulesets/src/types.ts new file mode 100644 index 0000000..c7b59e2 --- /dev/null +++ b/rulesets/src/types.ts @@ -0,0 +1,38 @@ +export type SheetData = Record; + +export interface FieldError { + field: string; + message: string; +} + +export interface ValidationResult { + valid: boolean; + errors: FieldError[]; +} + +export interface StatBlock { + label: string; + value: string | number; +} + +export interface DiceAction { + label: string; + /** TaleSpire dice notation, e.g. '1d20+5', '2d6' */ + diceString: string; + category: 'attack' | 'skill' | 'save' | 'damage' | 'custom'; +} + +/** Opaque render output — resolved by Svelte, not a VDOM */ +export type VNode = unknown; + +export interface Ruleset { + id: string; + name: string; + /** semver */ + version: string; + defaultSheet(): SheetData; + validate(sheet: SheetData): ValidationResult; + getStatBlocks(sheet: SheetData): StatBlock[]; + getDiceActions(sheet: SheetData): DiceAction[]; + renderSheet(sheet: SheetData): VNode; +} diff --git a/rulesets/tsconfig.json b/rulesets/tsconfig.json new file mode 100644 index 0000000..6d0cf4c --- /dev/null +++ b/rulesets/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/**/*.ts"] +} diff --git a/symbiote/index.html b/symbiote/index.html new file mode 100644 index 0000000..7d45e6a --- /dev/null +++ b/symbiote/index.html @@ -0,0 +1,12 @@ + + + + + + Campaign Manager + + +
+ + + diff --git a/symbiote/package.json b/symbiote/package.json new file mode 100644 index 0000000..218480b --- /dev/null +++ b/symbiote/package.json @@ -0,0 +1,20 @@ +{ + "name": "@campaign-manager/symbiote", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "svelte": "^5.0.0", + "@campaign-manager/rulesets": "workspace:*" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/symbiote/public/manifest.json b/symbiote/public/manifest.json new file mode 100644 index 0000000..56d67da --- /dev/null +++ b/symbiote/public/manifest.json @@ -0,0 +1,11 @@ +{ + "apiVersion": "v0.1", + "name": "Campaign Manager", + "version": "0.1.0", + "author": "", + "description": "Authenticated campaign and character management for TaleSpire", + "interopId": "com.campaign-manager.symbiote", + "entryPoint": "index.html", + "extras": ["colorStyles", "fonts", "diceFinder", "icons"], + "runInBackground": false +} diff --git a/symbiote/src/App.svelte b/symbiote/src/App.svelte new file mode 100644 index 0000000..59a0a7f --- /dev/null +++ b/symbiote/src/App.svelte @@ -0,0 +1,24 @@ + + +{#if $currentView === 'login'} + +{:else if $currentView === 'group-list'} + +{:else if $currentView === 'group-detail'} + +{:else if $currentView === 'character-sheet'} + +{:else if $currentView === 'roll-history'} + +{/if} diff --git a/symbiote/src/lib/api.ts b/symbiote/src/lib/api.ts new file mode 100644 index 0000000..5ca8a43 --- /dev/null +++ b/symbiote/src/lib/api.ts @@ -0,0 +1,76 @@ +import { get } from 'svelte/store'; +import { accessToken, refreshToken, saveTokens, clearTokens, isBoardTransition } from './store'; + +export const BASE_URL = 'https://api.campaign-manager.example.com/v1'; + +interface RequestOptions extends RequestInit { + skipAuth?: boolean; +} + +async function silentRefresh(): Promise { + const rt = get(refreshToken); + if (!rt) return false; + + const res = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ refreshToken: rt }), + }); + + if (!res.ok) { + await clearTokens(); + return false; + } + + const data = await res.json() as { accessToken: string; refreshToken: string }; + await saveTokens(data.accessToken, data.refreshToken); + return true; +} + +async function apiFetch(path: string, options: RequestOptions = {}): Promise { + if (get(isBoardTransition)) { + throw new Error('API call suppressed during board transition'); + } + + const { skipAuth = false, ...fetchOptions } = options; + const headers = new Headers(fetchOptions.headers); + + if (!skipAuth) { + const token = get(accessToken); + if (token) headers.set('Authorization', `Bearer ${token}`); + } + + if (fetchOptions.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + let res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers }); + + if (res.status === 401 && !skipAuth) { + const refreshed = await silentRefresh(); + if (refreshed) { + const newToken = get(accessToken); + if (newToken) headers.set('Authorization', `Bearer ${newToken}`); + res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers }); + } + } + + return res; +} + +export const api = { + get: (path: string, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'GET' }), + + post: (path: string, body?: unknown, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }), + + put: (path: string, body?: unknown, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }), + + patch: (path: string, body?: unknown, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }), + + delete: (path: string, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'DELETE' }), +}; diff --git a/symbiote/src/lib/store.ts b/symbiote/src/lib/store.ts new file mode 100644 index 0000000..fb28cca --- /dev/null +++ b/symbiote/src/lib/store.ts @@ -0,0 +1,56 @@ +import { writable } from 'svelte/store'; + +export interface User { + id: string; + email: string; + displayName: string; + emailVerified: boolean; +} + +export interface Group { + id: string; + name: string; + dmUserId: string; + rulesetId: string; + description: string; + createdAt: string; +} + +export const accessToken = writable(null); +export const refreshToken = writable(null); +export const currentUser = writable(null); +export const currentGroup = writable(null); +/** True while the board is transitioning (client leave/join); suppresses API calls */ +export const isBoardTransition = writable(false); + +const STORAGE_KEY_ACCESS = 'cm_access_token'; +const STORAGE_KEY_REFRESH = 'cm_refresh_token'; + +export async function loadTokens(): Promise { + const [access, refresh] = await Promise.all([ + TS.storage.getItem(STORAGE_KEY_ACCESS), + TS.storage.getItem(STORAGE_KEY_REFRESH), + ]); + accessToken.set(access); + refreshToken.set(refresh); +} + +export async function saveTokens(access: string, refresh: string): Promise { + await Promise.all([ + TS.storage.setItem(STORAGE_KEY_ACCESS, access), + TS.storage.setItem(STORAGE_KEY_REFRESH, refresh), + ]); + accessToken.set(access); + refreshToken.set(refresh); +} + +export async function clearTokens(): Promise { + await Promise.all([ + TS.storage.removeItem(STORAGE_KEY_ACCESS), + TS.storage.removeItem(STORAGE_KEY_REFRESH), + ]); + accessToken.set(null); + refreshToken.set(null); + currentUser.set(null); + currentGroup.set(null); +} diff --git a/symbiote/src/lib/ts-api.d.ts b/symbiote/src/lib/ts-api.d.ts new file mode 100644 index 0000000..facc3fa --- /dev/null +++ b/symbiote/src/lib/ts-api.d.ts @@ -0,0 +1,41 @@ +/** Ambient declarations for the TaleSpire Symbiote API injected into the page globally */ + +interface DiceResult { + notation: string; + results: number[]; + total: number; +} + +declare const TS: { + /** Resolves once the TaleSpire API is ready */ + hasInitialized: Promise; + + storage: { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + }; + + dice: { + /** Place dice in the player's hand for rolling */ + putDiceInHand(diceString: string): Promise; + onDiceResult: { + addListener(cb: (result: DiceResult) => void): void; + }; + }; + + players: { + onClientJoinEvent: { + addListener(cb: () => void): void; + }; + onClientLeaveEvent: { + addListener(cb: () => void): void; + }; + }; + + sync: { + /** Broadcast transient state to all clients on the same board */ + send(data: unknown): void; + addListener(cb: (data: unknown) => void): void; + }; +}; diff --git a/symbiote/src/main.ts b/symbiote/src/main.ts new file mode 100644 index 0000000..d5eaf47 --- /dev/null +++ b/symbiote/src/main.ts @@ -0,0 +1,11 @@ +import App from './App.svelte'; +import { loadTokens, isBoardTransition } from './lib/store'; + +// Suppress API calls while the board is transitioning between scenes +TS.players.onClientLeaveEvent.addListener(() => isBoardTransition.set(true)); +TS.players.onClientJoinEvent.addListener(() => isBoardTransition.set(false)); + +await TS.hasInitialized; +await loadTokens(); + +new App({ target: document.getElementById('app')! }); diff --git a/symbiote/src/views/CharacterSheet.svelte b/symbiote/src/views/CharacterSheet.svelte new file mode 100644 index 0000000..2db5cf4 --- /dev/null +++ b/symbiote/src/views/CharacterSheet.svelte @@ -0,0 +1,8 @@ + + + +
+

Character Sheet

+
diff --git a/symbiote/src/views/GroupDetail.svelte b/symbiote/src/views/GroupDetail.svelte new file mode 100644 index 0000000..745629c --- /dev/null +++ b/symbiote/src/views/GroupDetail.svelte @@ -0,0 +1,8 @@ + + + +
+

Group Detail

+
diff --git a/symbiote/src/views/GroupList.svelte b/symbiote/src/views/GroupList.svelte new file mode 100644 index 0000000..ee7ad66 --- /dev/null +++ b/symbiote/src/views/GroupList.svelte @@ -0,0 +1,8 @@ + + + +
+

Your Groups

+
diff --git a/symbiote/src/views/Login.svelte b/symbiote/src/views/Login.svelte new file mode 100644 index 0000000..f729763 --- /dev/null +++ b/symbiote/src/views/Login.svelte @@ -0,0 +1,9 @@ + + + + diff --git a/symbiote/src/views/RollHistory.svelte b/symbiote/src/views/RollHistory.svelte new file mode 100644 index 0000000..3cd8a02 --- /dev/null +++ b/symbiote/src/views/RollHistory.svelte @@ -0,0 +1,8 @@ + + + +
+

Roll History

+
diff --git a/symbiote/tsconfig.json b/symbiote/tsconfig.json new file mode 100644 index 0000000..3c707c6 --- /dev/null +++ b/symbiote/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "isolatedModules": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*.ts", "src/**/*.svelte"] +} diff --git a/symbiote/vite.config.ts b/symbiote/vite.config.ts new file mode 100644 index 0000000..d3c477e --- /dev/null +++ b/symbiote/vite.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: 'dist', + emptyOutDir: true, + target: 'es2022', + }, +}); diff --git a/web/build/404.html b/web/build/404.html new file mode 100644 index 0000000..a822853 --- /dev/null +++ b/web/build/404.html @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + +
+ +
+ + diff --git a/web/build/_app/env.js b/web/build/_app/env.js new file mode 100644 index 0000000..f5427da --- /dev/null +++ b/web/build/_app/env.js @@ -0,0 +1 @@ +export const env={} \ No newline at end of file diff --git a/web/build/_app/immutable/chunks/Bzak7iHL.js b/web/build/_app/immutable/chunks/Bzak7iHL.js new file mode 100644 index 0000000..e8f20e7 --- /dev/null +++ b/web/build/_app/immutable/chunks/Bzak7iHL.js @@ -0,0 +1 @@ +var e;typeof window<"u"&&((e=window.__svelte??(window.__svelte={})).v??(e.v=new Set)).add("5"); diff --git a/web/build/_app/immutable/chunks/C5YqYP7P.js b/web/build/_app/immutable/chunks/C5YqYP7P.js new file mode 100644 index 0000000..d516516 --- /dev/null +++ b/web/build/_app/immutable/chunks/C5YqYP7P.js @@ -0,0 +1 @@ +import{s as c,g as l}from"./DNqN6DmX.js";import{b as o,d as b,n as a,m as d,g as p,e as g}from"./CUCwB180.js";let s=!1,i=Symbol();function m(e,u,r){const n=r[u]??(r[u]={store:null,source:d(void 0),unsubscribe:a});if(n.store!==e&&!(i in r))if(n.unsubscribe(),n.store=e??null,e==null)n.source.v=void 0,n.unsubscribe=a;else{var t=!0;n.unsubscribe=c(e,f=>{t?n.source.v=f:g(n.source,f)}),t=!1}return e&&i in r?l(e):p(n.source)}function y(){const e={};function u(){o(()=>{for(var r in e)e[r].unsubscribe();b(e,i,{enumerable:!1,value:!0})})}return[e,u]}function N(e){var u=s;try{return s=!1,[e(),s]}finally{s=u}}export{m as a,N as c,y as s}; diff --git a/web/build/_app/immutable/chunks/CAYgxOZ1.js b/web/build/_app/immutable/chunks/CAYgxOZ1.js new file mode 100644 index 0000000..dc17f74 --- /dev/null +++ b/web/build/_app/immutable/chunks/CAYgxOZ1.js @@ -0,0 +1 @@ +import{w as a}from"./CUCwB180.js";a(); diff --git a/web/build/_app/immutable/chunks/CGowzvwH.js b/web/build/_app/immutable/chunks/CGowzvwH.js new file mode 100644 index 0000000..b32e6e4 --- /dev/null +++ b/web/build/_app/immutable/chunks/CGowzvwH.js @@ -0,0 +1 @@ +import{s as e}from"./YzYuob9f.js";const r=()=>{const s=e;return{page:{subscribe:s.page.subscribe},navigating:{subscribe:s.navigating.subscribe},updated:s.updated}},b={subscribe(s){return r().page.subscribe(s)}};export{b as p}; diff --git a/web/build/_app/immutable/chunks/CUCwB180.js b/web/build/_app/immutable/chunks/CUCwB180.js new file mode 100644 index 0000000..c62ebd2 --- /dev/null +++ b/web/build/_app/immutable/chunks/CUCwB180.js @@ -0,0 +1 @@ +var ot=Object.defineProperty;var mn=e=>{throw TypeError(e)};var ct=(e,n,t)=>n in e?ot(e,n,{enumerable:!0,configurable:!0,writable:!0,value:t}):e[n]=t;var ne=(e,n,t)=>ct(e,typeof n!="symbol"?n+"":n,t),Ze=(e,n,t)=>n.has(e)||mn("Cannot "+t);var d=(e,n,t)=>(Ze(e,n,"read from private field"),t?t.call(e):n.get(e)),D=(e,n,t)=>n.has(e)?mn("Cannot add the same private member more than once"):n instanceof WeakSet?n.add(e):n.set(e,t),z=(e,n,t,r)=>(Ze(e,n,"write to private field"),r?r.call(e,t):n.set(e,t),t),L=(e,n,t)=>(Ze(e,n,"access private method"),t);var _t=Array.isArray,vt=Array.prototype.indexOf,me=Array.prototype.includes,fr=Array.from,lr=Object.defineProperty,Oe=Object.getOwnPropertyDescriptor,dt=Object.getOwnPropertyDescriptors,ht=Object.prototype,pt=Array.prototype,Nn=Object.getPrototypeOf,En=Object.isExtensible;const wt=()=>{};function ir(e){return e()}function yt(e){for(var n=0;n{e=r,n=s});return{promise:t,resolve:e,reject:n}}const T=2,Ee=4,Fe=8,Dn=1<<24,Q=16,H=32,le=64,gt=128,I=512,E=1024,x=2048,B=4096,q=8192,U=16384,_e=32768,Ue=65536,Tn=1<<17,mt=1<<18,Me=1<<19,Pn=1<<20,ie=65536,Xe=1<<21,un=1<<22,J=1<<23,De=Symbol("$state"),ar=Symbol("legacy props"),G=new class extends Error{constructor(){super(...arguments);ne(this,"name","StaleReactionError");ne(this,"message","The reaction that called `getAbortSignal()` was re-run or destroyed")}},Ke=3,In=8;function Et(){throw new Error("https://svelte.dev/e/async_derived_orphan")}function Tt(e){throw new Error("https://svelte.dev/e/effect_in_teardown")}function bt(){throw new Error("https://svelte.dev/e/effect_in_unowned_derived")}function At(e){throw new Error("https://svelte.dev/e/effect_orphan")}function Rt(){throw new Error("https://svelte.dev/e/effect_update_depth_exceeded")}function or(){throw new Error("https://svelte.dev/e/hydration_failed")}function cr(e){throw new Error("https://svelte.dev/e/props_invalid_value")}function St(){throw new Error("https://svelte.dev/e/state_descriptors_fixed")}function xt(){throw new Error("https://svelte.dev/e/state_prototype_fixed")}function kt(){throw new Error("https://svelte.dev/e/state_unsafe_mutation")}function _r(){throw new Error("https://svelte.dev/e/svelte_boundary_reset_onerror")}const vr=1,dr=2,hr=4,pr=8,wr=16,yr=1,gr=2,Nt="[",Ot="[!",mr="[?",Dt="]",on={},b=Symbol(),Pt="http://www.w3.org/1999/xhtml";function cn(e){console.warn("https://svelte.dev/e/hydration_mismatch")}function Er(){console.warn("https://svelte.dev/e/svelte_boundary_reset_noop")}let ae=!1;function Tr(e){ae=e}let m;function Te(e){if(e===null)throw cn(),on;return m=e}function br(){return Te(ee(m))}function Ar(e){if(ae){if(ee(m)!==null)throw cn(),on;m=e}}function Rr(e=1){if(ae){for(var n=e,t=m;n--;)t=ee(t);m=t}}function Sr(e=!0){for(var n=0,t=m;;){if(t.nodeType===In){var r=t.data;if(r===Dt){if(n===0)return t;n-=1}else(r===Nt||r===Ot||r[0]==="["&&!isNaN(Number(r.slice(1))))&&(n+=1)}var s=ee(t);e&&t.remove(),t=s}}function xr(e){if(!e||e.nodeType!==In)throw cn(),on;return e.data}function Cn(e){return e===this.v}function It(e,n){return e!=e?n==n:e!==n||e!==null&&typeof e=="object"||typeof e=="function"}function Fn(e){return!It(e,this.v)}let $e=!1;function kr(){$e=!0}let S=null;function He(e){S=e}function Nr(e,n=!1,t){S={p:S,i:!1,c:null,e:null,s:e,x:null,l:$e&&!n?{s:null,u:null,$:[]}:null}}function Or(e){var n=S,t=n.e;if(t!==null){n.e=null;for(var r of t)Xn(r)}return n.i=!0,S=n.p,{}}function je(){return!$e||S!==null&&S.l===null}let re=[];function Mn(){var e=re;re=[],yt(e)}function bn(e){if(re.length===0&&!Pe){var n=re;queueMicrotask(()=>{n===re&&Mn()})}re.push(e)}function Ct(){for(;re.length>0;)Mn()}function Ft(e){var n=w;if(n===null)return v.f|=J,e;if((n.f&_e)===0&&(n.f&Ee)===0)throw e;Be(e,n)}function Be(e,n){for(;n!==null;){if((n.f>)!==0){if((n.f&_e)===0)throw e;try{n.b.error(e);return}catch(t){e=t}}n=n.parent}throw e}const Mt=-7169;function y(e,n){e.f=e.f&Mt|n}function _n(e){(e.f&I)!==0||e.deps===null?y(e,E):y(e,B)}function jn(e){if(e!==null)for(const n of e)(n.f&T)===0||(n.f&ie)===0||(n.f^=ie,jn(n.deps))}function jt(e,n,t){(e.f&x)!==0?n.add(e):(e.f&B)!==0&&t.add(e),jn(e.deps),y(e,E)}const xe=new Set;let h=null,A=null,Qe=null,Pe=!1,Je=!1,ve=null,Ye=null;var An=0;let Lt=1;var de,he,pe,we,Ce,$,ye,Z,Y,ge,R,en,nn,tn,rn,Ln;const Ge=class Ge{constructor(){D(this,R);ne(this,"id",Lt++);ne(this,"current",new Map);ne(this,"previous",new Map);D(this,de,new Set);D(this,he,new Set);D(this,pe,0);D(this,we,0);D(this,Ce,null);D(this,$,[]);D(this,ye,new Set);D(this,Z,new Set);D(this,Y,new Map);ne(this,"is_fork",!1);D(this,ge,!1)}skip_effect(n){d(this,Y).has(n)||d(this,Y).set(n,{d:[],m:[]})}unskip_effect(n){var t=d(this,Y).get(n);if(t){d(this,Y).delete(n);for(var r of t.d)y(r,x),this.schedule(r);for(r of t.m)y(r,B),this.schedule(r)}}capture(n,t){t!==b&&!this.previous.has(n)&&this.previous.set(n,t),(n.f&J)===0&&(this.current.set(n,n.v),A==null||A.set(n,n.v))}activate(){h=this}deactivate(){h=null,A=null}flush(){try{if(Je=!0,h=this,!L(this,R,en).call(this)){for(const n of d(this,ye))d(this,Z).delete(n),y(n,x),this.schedule(n);for(const n of d(this,Z))y(n,B),this.schedule(n)}L(this,R,nn).call(this)}finally{An=0,Qe=null,ve=null,Ye=null,Je=!1,h=null,A=null,W.clear()}}discard(){for(const n of d(this,he))n(this);d(this,he).clear()}increment(n){z(this,pe,d(this,pe)+1),n&&z(this,we,d(this,we)+1)}decrement(n,t){z(this,pe,d(this,pe)-1),n&&z(this,we,d(this,we)-1),!(d(this,ge)||t)&&(z(this,ge,!0),bn(()=>{z(this,ge,!1),this.flush()}))}oncommit(n){d(this,de).add(n)}ondiscard(n){d(this,he).add(n)}settled(){return(d(this,Ce)??z(this,Ce,On())).promise}static ensure(){if(h===null){const n=h=new Ge;Je||(xe.add(h),Pe||bn(()=>{h===n&&n.flush()}))}return h}apply(){}schedule(n){var s;if(Qe=n,(s=n.b)!=null&&s.is_pending&&(n.f&(Ee|Fe|Dn))!==0&&(n.f&_e)===0){n.b.defer_effect(n);return}for(var t=n;t.parent!==null;){t=t.parent;var r=t.f;if(ve!==null&&t===w&&(v===null||(v.f&T)===0))return;if((r&(le|H))!==0){if((r&E)===0)return;t.f^=E}}d(this,$).push(t)}};de=new WeakMap,he=new WeakMap,pe=new WeakMap,we=new WeakMap,Ce=new WeakMap,$=new WeakMap,ye=new WeakMap,Z=new WeakMap,Y=new WeakMap,ge=new WeakMap,R=new WeakSet,en=function(){return this.is_fork||d(this,we)>0},nn=function(){var a,l;An++>1e3&&qt();const n=d(this,$);z(this,$,[]),this.apply();var t=ve=[],r=[],s=Ye=[];for(const i of n)L(this,R,tn).call(this,i,t,r);if(h=null,s.length>0){var f=Ge.ensure();for(const i of s)f.schedule(i)}if(ve=null,Ye=null,L(this,R,en).call(this)){L(this,R,rn).call(this,r),L(this,R,rn).call(this,t);for(const[i,o]of d(this,Y))Un(i,o)}else{d(this,ye).clear(),d(this,Z).clear();for(const i of d(this,de))i(this);d(this,de).clear(),Rn(r),Rn(t),d(this,pe)===0&&L(this,R,Ln).call(this),(a=d(this,Ce))==null||a.resolve()}var u=h;u!==null&&(xe.add(u),L(l=u,R,nn).call(l))},tn=function(n,t,r){n.f^=E;for(var s=n.first;s!==null;){var f=s.f,u=(f&(H|le))!==0,a=u&&(f&E)!==0,l=a||(f&q)!==0||d(this,Y).has(s);if(!l&&s.fn!==null){u?s.f^=E:(f&Ee)!==0?t.push(s):Le(s)&&((f&Q)!==0&&d(this,Z).add(s),Ae(s));var i=s.first;if(i!==null){s=i;continue}}for(;s!==null;){var o=s.next;if(o!==null){s=o;break}s=s.parent}}},rn=function(n){for(var t=0;t1){this.previous.clear();var n=h,t=A,r=!0;for(const f of xe){if(f===this){r=!1;continue}const u=[];for(const[l,i]of this.current){if(f.current.has(l))if(r&&i!==f.current.get(l))f.current.set(l,i);else continue;u.push(l)}if(u.length===0)continue;const a=[...f.current.keys()].filter(l=>!this.current.has(l));if(a.length>0){f.activate();const l=new Set,i=new Map;for(const o of u)Yn(o,a,l,i);if(d(f,$).length>0){f.apply();for(const o of d(f,$))L(s=f,R,tn).call(s,o,[],[])}f.deactivate()}}h=n,A=t}d(this,Y).clear(),xe.delete(this)};let ue=Ge;function Yt(e){var n=Pe;Pe=!0;try{for(var t;;){if(Ct(),h===null)return t;h.flush()}}finally{Pe=n}}function qt(){try{Rt()}catch(e){Be(e,Qe)}}let M=null;function Rn(e){var n=e.length;if(n!==0){for(var t=0;t0)){W.clear();for(const s of M){if((s.f&(U|q))!==0)continue;const f=[s];let u=s.parent;for(;u!==null;)M.has(u)&&(M.delete(u),f.push(u)),u=u.parent;for(let a=f.length-1;a>=0;a--){const l=f[a];(l.f&(U|q))===0&&Ae(l)}}M.clear()}}M=null}}function Yn(e,n,t,r){if(!t.has(e)&&(t.add(e),e.reactions!==null))for(const s of e.reactions){const f=s.f;(f&T)!==0?Yn(s,n,t,r):(f&(un|Q))!==0&&(f&x)===0&&qn(s,n,r)&&(y(s,x),vn(s))}}function qn(e,n,t){const r=t.get(e);if(r!==void 0)return r;if(e.deps!==null)for(const s of e.deps){if(me.call(n,s))return!0;if((s.f&T)!==0&&qn(s,n,t))return t.set(s,!0),!0}return t.set(e,!1),!1}function vn(e){h.schedule(e)}function Un(e,n){if(!((e.f&H)!==0&&(e.f&E)!==0)){(e.f&x)!==0?n.d.push(e):(e.f&B)!==0&&n.m.push(e),y(e,E);for(var t=e.first;t!==null;)Un(t,n),t=t.next}}function Ut(e,n,t,r){const s=je()?dn:Vt;var f=e.filter(c=>!c.settled);if(t.length===0&&f.length===0){r(n.map(s));return}var u=w,a=Ht(),l=f.length===1?f[0].promise:f.length>1?Promise.all(f.map(c=>c.promise)):null;function i(c){a();try{r(c)}catch(g){(u.f&U)===0&&Be(g,u)}Ve()}if(t.length===0){l.then(()=>i(n.map(s)));return}var o=Hn();function _(){Promise.all(t.map(c=>Bt(c))).then(c=>i([...n.map(s),...c])).catch(c=>Be(c,u)).finally(()=>o())}l?l.then(()=>{a(),_(),Ve()}):_()}function Ht(){var e=w,n=v,t=S,r=h;return function(f=!0){be(e),X(n),He(t),f&&(e.f&U)===0&&(r==null||r.activate(),r==null||r.apply())}}function Ve(e=!0){be(null),X(null),He(null),e&&(h==null||h.deactivate())}function Hn(){var e=w.b,n=h,t=e.is_rendered();return e.update_pending_count(1,n),n.increment(t),(r=!1)=>{e.update_pending_count(-1,n),n.decrement(t,r)}}function dn(e){var n=T|x,t=v!==null&&(v.f&T)!==0?v:null;return w!==null&&(w.f|=Me),{ctx:S,deps:null,effects:null,equals:Cn,f:n,fn:e,reactions:null,rv:0,v:b,wv:0,parent:t??w,ac:null}}function Bt(e,n,t){let r=w;r===null&&Et();var s=void 0,f=pn(b),u=!v,a=new Map;return Xt(()=>{var g;var l=w,i=On();s=i.promise;try{Promise.resolve(e()).then(i.resolve,i.reject).finally(Ve)}catch(p){i.reject(p),Ve()}var o=h;if(u){if((l.f&_e)!==0)var _=Hn();if(r.b.is_rendered())(g=a.get(o))==null||g.reject(G),a.delete(o);else{for(const p of a.values())p.reject(G);a.clear()}a.set(o,i)}const c=(p,F=void 0)=>{if(_){var k=F===G;_(k)}if(!(F===G||(l.f&U)!==0)){if(o.activate(),F)f.f|=J,fn(f,F);else{(f.f&J)!==0&&(f.f^=J),fn(f,p);for(const[Re,Se]of a){if(a.delete(Re),Re===o)break;Se.reject(G)}}o.deactivate()}};i.promise.then(c,p=>c(null,p||"unknown"))}),Wt(()=>{for(const l of a.values())l.reject(G)}),new Promise(l=>{function i(o){function _(){o===s?l(f):i(s)}o.then(_,_)}i(s)})}function Dr(e){const n=dn(e);return rt(n),n}function Vt(e){const n=dn(e);return n.equals=Fn,n}function zt(e){var n=e.effects;if(n!==null){e.effects=null;for(var t=0;t0&&!zn&&$t()}return n}function $t(){zn=!1;for(const e of sn)(e.f&E)!==0&&y(e,B),Le(e)&&Ae(e);sn.clear()}function We(e){te(e,e.v+1)}function Gn(e,n,t){var r=e.reactions;if(r!==null)for(var s=je(),f=r.length,u=0;u{if(fe===f)return a();var l=v,i=fe;X(null),kn(f);var o=a();return X(l),kn(i),o};return r&&t.set("length",K(e.length)),new Proxy(e,{defineProperty(a,l,i){(!("value"in i)||i.configurable===!1||i.enumerable===!1||i.writable===!1)&&St();var o=t.get(l);return o===void 0?u(()=>{var _=K(i.value);return t.set(l,_),_}):te(o,i.value,!0),!0},deleteProperty(a,l){var i=t.get(l);if(i===void 0){if(l in a){const o=u(()=>K(b));t.set(l,o),We(s)}}else te(i,b),We(s);return!0},get(a,l,i){var g;if(l===De)return e;var o=t.get(l),_=l in a;if(o===void 0&&(!_||(g=Oe(a,l))!=null&&g.writable)&&(o=u(()=>{var p=ke(_?a[l]:b),F=K(p);return F}),t.set(l,o)),o!==void 0){var c=Ne(o);return c===b?void 0:c}return Reflect.get(a,l,i)},getOwnPropertyDescriptor(a,l){var i=Reflect.getOwnPropertyDescriptor(a,l);if(i&&"value"in i){var o=t.get(l);o&&(i.value=Ne(o))}else if(i===void 0){var _=t.get(l),c=_==null?void 0:_.v;if(_!==void 0&&c!==b)return{enumerable:!0,configurable:!0,value:c,writable:!0}}return i},has(a,l){var c;if(l===De)return!0;var i=t.get(l),o=i!==void 0&&i.v!==b||Reflect.has(a,l);if(i!==void 0||w!==null&&(!o||(c=Oe(a,l))!=null&&c.writable)){i===void 0&&(i=u(()=>{var g=o?ke(a[l]):b,p=K(g);return p}),t.set(l,i));var _=Ne(i);if(_===b)return!1}return o},set(a,l,i,o){var gn;var _=t.get(l),c=l in a;if(r&&l==="length")for(var g=i;g<_.v;g+=1){var p=t.get(g+"");p!==void 0?te(p,b):g in a&&(p=u(()=>K(b)),t.set(g+"",p))}if(_===void 0)(!c||(gn=Oe(a,l))!=null&&gn.writable)&&(_=u(()=>K(void 0)),te(_,ke(i)),t.set(l,_));else{c=_.v!==b;var F=u(()=>ke(i));te(_,F)}var k=Reflect.getOwnPropertyDescriptor(a,l);if(k!=null&&k.set&&k.set.call(o,i),!c){if(r&&typeof l=="string"){var Re=t.get("length"),Se=Number(l);Number.isInteger(Se)&&Se>=Re.v&&te(Re,Se+1)}We(s)}return!0},ownKeys(a){Ne(s);var l=Reflect.ownKeys(a).filter(_=>{var c=t.get(_);return c===void 0||c.v!==b});for(var[i,o]of t)o.v!==b&&!(i in a)&&l.push(i);return l},setPrototypeOf(){xt()}})}var Sn,Zt,Kn,$n;function Ir(){if(Sn===void 0){Sn=window,Zt=/Firefox/.test(navigator.userAgent);var e=Element.prototype,n=Node.prototype,t=Text.prototype;Kn=Oe(n,"firstChild").get,$n=Oe(n,"nextSibling").get,En(e)&&(e.__click=void 0,e.__className=void 0,e.__attributes=null,e.__style=void 0,e.__e=void 0),En(t)&&(t.__t=void 0)}}function ze(e=""){return document.createTextNode(e)}function ln(e){return Kn.call(e)}function ee(e){return $n.call(e)}function Cr(e,n){if(!ae)return ln(e);var t=ln(m);if(t===null)t=m.appendChild(ze());else if(n&&t.nodeType!==Ke){var r=ze();return t==null||t.before(r),Te(r),r}return n&&wn(t),Te(t),t}function Fr(e,n=!1){if(!ae){var t=ln(e);return t instanceof Comment&&t.data===""?ee(t):t}if(n){if((m==null?void 0:m.nodeType)!==Ke){var r=ze();return m==null||m.before(r),Te(r),r}wn(m)}return m}function Mr(e,n=1,t=!1){let r=ae?m:e;for(var s;n--;)s=r,r=ee(r);if(!ae)return r;if(t){if((r==null?void 0:r.nodeType)!==Ke){var f=ze();return r===null?s==null||s.after(f):r.before(f),Te(f),f}wn(r)}return Te(r),r}function jr(e){e.textContent=""}function Lr(){return!1}function Yr(e,n,t){return document.createElementNS(Pt,e,void 0)}function wn(e){if(e.nodeValue.length<65536)return;let n=e.nextSibling;for(;n!==null&&n.nodeType===Ke;)n.remove(),e.nodeValue+=n.nodeValue,n=e.nextSibling}function Zn(e){var n=v,t=w;X(null),be(null);try{return e()}finally{X(n),be(t)}}function Jn(e){w===null&&(v===null&&At(),bt()),ce&&Tt()}function Jt(e,n){var t=n.last;t===null?n.last=n.first=e:(t.next=e,e.prev=t,n.last=e)}function V(e,n){var t=w;t!==null&&(t.f&q)!==0&&(e|=q);var r={ctx:S,deps:null,nodes:null,f:e|x|I,first:null,fn:n,last:null,next:null,parent:t,b:t&&t.b,prev:null,teardown:null,wv:0,ac:null},s=r;if((e&Ee)!==0)ve!==null?ve.push(r):ue.ensure().schedule(r);else if(n!==null){try{Ae(r)}catch(u){throw oe(r),u}s.deps===null&&s.teardown===null&&s.nodes===null&&s.first===s.last&&(s.f&Me)===0&&(s=s.first,(e&Q)!==0&&(e&Ue)!==0&&s!==null&&(s.f|=Ue))}if(s!==null&&(s.parent=t,t!==null&&Jt(s,t),v!==null&&(v.f&T)!==0&&(e&le)===0)){var f=v;(f.effects??(f.effects=[])).push(s)}return r}function Wn(){return v!==null&&!j}function Wt(e){const n=V(Fe,null);return y(n,E),n.teardown=e,n}function qr(e){Jn();var n=w.f,t=!v&&(n&H)!==0&&(n&_e)===0;if(t){var r=S;(r.e??(r.e=[])).push(e)}else return Xn(e)}function Xn(e){return V(Ee|Pn,e)}function Ur(e){return Jn(),V(Fe|Pn,e)}function Hr(e){ue.ensure();const n=V(le|Me,e);return(t={})=>new Promise(r=>{t.outro?nr(n,()=>{oe(n),r(void 0)}):(oe(n),r(void 0))})}function Br(e){return V(Ee,e)}function Xt(e){return V(un|Me,e)}function Vr(e,n=0){return V(Fe|n,e)}function zr(e,n=[],t=[],r=[]){Ut(r,n,t,s=>{V(Fe,()=>e(...s.map(Ne)))})}function Gr(e,n=0){var t=V(Q|n,e);return t}function Kr(e){return V(H|Me,e)}function Qn(e){var n=e.teardown;if(n!==null){const t=ce,r=v;xn(!0),X(null);try{n.call(null)}finally{xn(t),X(r)}}}function yn(e,n=!1){var t=e.first;for(e.first=e.last=null;t!==null;){const s=t.ac;s!==null&&Zn(()=>{s.abort(G)});var r=t.next;(t.f&le)!==0?t.parent=null:oe(t,n),t=r}}function Qt(e){for(var n=e.first;n!==null;){var t=n.next;(n.f&H)===0&&oe(n),n=t}}function oe(e,n=!0){var t=!1;(n||(e.f&mt)!==0)&&e.nodes!==null&&e.nodes.end!==null&&(er(e.nodes.start,e.nodes.end),t=!0),yn(e,n&&!t),Ie(e,0),y(e,U);var r=e.nodes&&e.nodes.t;if(r!==null)for(const f of r)f.stop();Qn(e);var s=e.parent;s!==null&&s.first!==null&&et(e),e.next=e.prev=e.teardown=e.ctx=e.deps=e.fn=e.nodes=e.ac=null}function er(e,n){for(;e!==null;){var t=e===n?null:ee(e);e.remove(),e=t}}function et(e){var n=e.parent,t=e.prev,r=e.next;t!==null&&(t.next=r),r!==null&&(r.prev=t),n!==null&&(n.first===e&&(n.first=r),n.last===e&&(n.last=t))}function nr(e,n,t=!0){var r=[];nt(e,r,!0);var s=()=>{t&&oe(e),n&&n()},f=r.length;if(f>0){var u=()=>--f||s();for(var a of r)a.out(u)}else s()}function nt(e,n,t){if((e.f&q)===0){e.f^=q;var r=e.nodes&&e.nodes.t;if(r!==null)for(const a of r)(a.is_global||t)&&n.push(a);for(var s=e.first;s!==null;){var f=s.next,u=(s.f&Ue)!==0||(s.f&H)!==0&&(e.f&Q)!==0;nt(s,n,u?t:!1),s=f}}}function $r(e){tt(e,!0)}function tt(e,n){if((e.f&q)!==0){e.f^=q,(e.f&E)===0&&(y(e,x),ue.ensure().schedule(e));for(var t=e.first;t!==null;){var r=t.next,s=(t.f&Ue)!==0||(t.f&H)!==0;tt(t,s?n:!1),t=r}var f=e.nodes&&e.nodes.t;if(f!==null)for(const u of f)(u.is_global||n)&&u.in()}}function Zr(e,n){if(e.nodes)for(var t=e.nodes.start,r=e.nodes.end;t!==null;){var s=t===r?null:ee(t);n.append(t),t=s}}let qe=!1,ce=!1;function xn(e){ce=e}let v=null,j=!1;function X(e){v=e}let w=null;function be(e){w=e}let C=null;function rt(e){v!==null&&(C===null?C=[e]:C.push(e))}let N=null,O=0,P=null;function tr(e){P=e}let st=1,se=0,fe=se;function kn(e){fe=e}function ft(){return++st}function Le(e){var n=e.f;if((n&x)!==0)return!0;if(n&T&&(e.f&=~ie),(n&B)!==0){for(var t=e.deps,r=t.length,s=0;se.wv)return!0}(n&I)!==0&&A===null&&y(e,E)}return!1}function lt(e,n,t=!0){var r=e.reactions;if(r!==null&&!(C!==null&&me.call(C,e)))for(var s=0;s{e.ac.abort(G)}),e.ac=null);try{e.f|=Xe;var o=e.fn,_=o();e.f|=_e;var c=e.deps,g=h==null?void 0:h.is_fork;if(N!==null){var p;if(g||Ie(e,O),c!==null&&O>0)for(c.length=O+N.length,p=0;pe.subscribe(n,s));return u.unsubscribe?()=>u.unsubscribe():u}const i=[];function q(e,n=c){let s=null;const u=new Set;function r(o){if(p(e,o)&&(e=o,s)){const f=!i.length;for(const t of u)t[1](),i.push(t,e);if(f){for(let t=0;t{u.delete(t),u.size===0&&s&&(s(),s=null)}}return{set:r,update:b,subscribe:_}}function k(e){let n;return g(e,s=>n=s)(),n}function x(e){l===null&&h(),m&&l.l!==null?w(l).m.push(e):d(()=>{const n=a(e);if(typeof n=="function")return n})}function w(e){var n=e.l;return n.u??(n.u={a:[],b:[],m:[]})}export{k as g,x as o,g as s,q as w}; diff --git a/web/build/_app/immutable/chunks/Db9w--lA.js b/web/build/_app/immutable/chunks/Db9w--lA.js new file mode 100644 index 0000000..7e6c183 --- /dev/null +++ b/web/build/_app/immutable/chunks/Db9w--lA.js @@ -0,0 +1,2 @@ +var Fe=Object.defineProperty;var le=r=>{throw TypeError(r)};var ke=(r,e,t)=>e in r?Fe(r,e,{enumerable:!0,configurable:!0,writable:!0,value:t}):r[e]=t;var q=(r,e,t)=>ke(r,typeof e!="symbol"?e+"":e,t),re=(r,e,t)=>e.has(r)||le("Cannot "+t);var s=(r,e,t)=>(re(r,e,"read from private field"),t?t.call(r):e.get(r)),d=(r,e,t)=>e.has(r)?le("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(r):e.set(r,t),n=(r,e,t,i)=>(re(r,e,"write to private field"),i?i.call(r,t):e.set(r,t),t),p=(r,e,t)=>(re(r,e,"access private method"),t);import{L as Ye,g as be,M as Ie,N as Ee,j as Me,O as de,P as J,E as Y,D as I,z as P,Q as ce,S as Ce,U as k,y as Te,V as se,W as _e,X as He,Y as ue,Z as Pe,_ as Ve,$ as Be,a0 as Z,a1 as G,a2 as pe,a3 as Le,a4 as $e,a5 as we,h as Se,a6 as je,a7 as ie,H as K,v as qe,a8 as xe,a9 as x,aa as We,F as ze,ab as Ue,ac as ge,ad as Je,ae as Qe,af as Xe,d as Ze,ag as ae,A as Ge,ah as Re,ai as Ke,aj as et,ak as ne,al as W,am as tt,an as rt,ao as st,ap as it,p as at,aq as nt,ar as ht,a as ft}from"./CUCwB180.js";import{b as ot}from"./pTMRHjpX.js";function lt(r){let e=0,t=Ee(0),i;return()=>{Ye()&&(be(t),Ie(()=>(e===0&&(i=Me(()=>r(()=>de(t)))),e+=1,()=>{J(()=>{e-=1,e===0&&(i==null||i(),i=void 0,de(t))})})))}}var dt=Qe|Xe;function ct(r,e,t,i){new _t(r,e,t,i)}var b,j,w,M,g,S,E,T,R,C,A,V,B,L,D,ee,f,De,Ne,Ae,he,Q,X,fe;class _t{constructor(e,t,i,o){d(this,f);q(this,"parent");q(this,"is_pending",!1);q(this,"transform_error");d(this,b);d(this,j,I?Y:null);d(this,w);d(this,M);d(this,g);d(this,S,null);d(this,E,null);d(this,T,null);d(this,R,null);d(this,C,0);d(this,A,0);d(this,V,!1);d(this,B,new Set);d(this,L,new Set);d(this,D,null);d(this,ee,lt(()=>(n(this,D,Ee(s(this,C))),()=>{n(this,D,null)})));var a;n(this,b,e),n(this,w,t),n(this,M,h=>{var _=P;_.b=this,_.f|=ce,i(h)}),this.parent=P.b,this.transform_error=o??((a=this.parent)==null?void 0:a.transform_error)??(h=>h),n(this,g,Ce(()=>{if(I){const h=s(this,j);ze();const _=h.data===Ue;if(h.data.startsWith(ge)){const c=JSON.parse(h.data.slice(ge.length));p(this,f,Ne).call(this,c)}else _?p(this,f,Ae).call(this):p(this,f,De).call(this)}else p(this,f,he).call(this)},dt)),I&&n(this,b,Y)}defer_effect(e){Be(e,s(this,B),s(this,L))}is_rendered(){return!this.is_pending&&(!this.parent||this.parent.is_rendered())}has_pending_snippet(){return!!s(this,w).pending}update_pending_count(e,t){p(this,f,fe).call(this,e,t),n(this,C,s(this,C)+e),!(!s(this,D)||s(this,V))&&(n(this,V,!0),J(()=>{n(this,V,!1),s(this,D)&&je(s(this,D),s(this,C))}))}get_effect_pending(){return s(this,ee).call(this),be(s(this,D))}error(e){var t=s(this,w).onerror;let i=s(this,w).failed;if(!t&&!i)throw e;s(this,S)&&(ie(s(this,S)),n(this,S,null)),s(this,E)&&(ie(s(this,E)),n(this,E,null)),s(this,T)&&(ie(s(this,T)),n(this,T,null)),I&&(K(s(this,j)),qe(),K(xe()));var o=!1,a=!1;const h=()=>{if(o){Je();return}o=!0,a&&We(),s(this,T)!==null&&se(s(this,T),()=>{n(this,T,null)}),p(this,f,X).call(this,()=>{p(this,f,he).call(this)})},_=l=>{try{a=!0,t==null||t(l,h),a=!1}catch(c){x(c,s(this,g)&&s(this,g).parent)}i&&n(this,T,p(this,f,X).call(this,()=>{try{return k(()=>{var c=P;c.b=this,c.f|=ce,i(s(this,b),()=>l,()=>h)})}catch(c){return x(c,s(this,g).parent),null}}))};J(()=>{var l;try{l=this.transform_error(e)}catch(c){x(c,s(this,g)&&s(this,g).parent);return}l!==null&&typeof l=="object"&&typeof l.then=="function"?l.then(_,c=>x(c,s(this,g)&&s(this,g).parent)):_(l)})}}b=new WeakMap,j=new WeakMap,w=new WeakMap,M=new WeakMap,g=new WeakMap,S=new WeakMap,E=new WeakMap,T=new WeakMap,R=new WeakMap,C=new WeakMap,A=new WeakMap,V=new WeakMap,B=new WeakMap,L=new WeakMap,D=new WeakMap,ee=new WeakMap,f=new WeakSet,De=function(){try{n(this,S,k(()=>s(this,M).call(this,s(this,b))))}catch(e){this.error(e)}},Ne=function(e){const t=s(this,w).failed;t&&n(this,T,k(()=>{t(s(this,b),()=>e,()=>()=>{})}))},Ae=function(){const e=s(this,w).pending;if(e){this.is_pending=!0,n(this,E,k(()=>e(s(this,b))));var t=_e;J(()=>{var i=n(this,R,document.createDocumentFragment()),o=Te();i.append(o),n(this,S,p(this,f,X).call(this,()=>k(()=>s(this,M).call(this,o)))),s(this,A)===0&&(s(this,b).before(i),n(this,R,null),se(s(this,E),()=>{n(this,E,null)}),p(this,f,Q).call(this,t))})}},he=function(){var e=_e;try{if(this.is_pending=this.has_pending_snippet(),n(this,A,0),n(this,C,0),n(this,S,k(()=>{s(this,M).call(this,s(this,b))})),s(this,A)>0){var t=n(this,R,document.createDocumentFragment());He(s(this,S),t);const i=s(this,w).pending;n(this,E,k(()=>i(s(this,b))))}else p(this,f,Q).call(this,e)}catch(i){this.error(i)}},Q=function(e){this.is_pending=!1;for(const t of s(this,B))ue(t,Pe),e.schedule(t);for(const t of s(this,L))ue(t,Ve),e.schedule(t);s(this,B).clear(),s(this,L).clear()},X=function(e){var t=P,i=we,o=Se;Z(s(this,g)),G(s(this,g)),pe(s(this,g).ctx);try{return Le.ensure(),e()}catch(a){return $e(a),null}finally{Z(t),G(i),pe(o)}},fe=function(e,t){var i;if(!this.has_pending_snippet()){this.parent&&p(i=this.parent,f,fe).call(i,e,t);return}n(this,A,s(this,A)+e),s(this,A)===0&&(p(this,f,Q).call(this,t),s(this,E)&&se(s(this,E),()=>{n(this,E,null)}),s(this,R)&&(s(this,b).before(s(this,R)),n(this,R,null)))};const ut=["touchstart","touchmove"];function pt(r){return ut.includes(r)}const z=Symbol("events"),gt=new Set,ve=new Set;let ye=null;function me(r){var O,m;var e=this,t=e.ownerDocument,i=r.type,o=((O=r.composedPath)==null?void 0:O.call(r))||[],a=o[0]||r.target;ye=r;var h=0,_=ye===r&&r[z];if(_){var l=o.indexOf(_);if(l!==-1&&(e===document||e===window)){r[z]=e;return}var c=o.indexOf(e);if(c===-1)return;l<=c&&(h=l)}if(a=o[h]||r.target,a!==e){Ze(r,"currentTarget",{configurable:!0,get(){return a||t}});var H=we,$=P;G(null),Z(null);try{for(var N,v=[];a!==null;){var u=a.assignedSlot||a.parentNode||a.host||null;try{var y=(m=a[z])==null?void 0:m[i];y!=null&&(!a.disabled||r.target===a)&&y.call(a,r)}catch(F){N?v.push(F):N=F}if(r.cancelBubble||u===e||u===null)break;a=u}if(N){for(let F of v)queueMicrotask(()=>{throw F});throw N}}finally{r[z]=e,delete r.currentTarget,G(H),Z($)}}}function Et(r,e){var t=e==null?"":typeof e=="object"?`${e}`:e;t!==(r.__t??(r.__t=r.nodeValue))&&(r.__t=t,r.nodeValue=`${t}`)}function vt(r,e){return Oe(r,e)}function Tt(r,e){ae(),e.intro=e.intro??!1;const t=e.target,i=I,o=Y;try{for(var a=Ge(t);a&&(a.nodeType!==Re||a.data!==Ke);)a=et(a);if(!a)throw ne;W(!0),K(a);const h=Oe(r,{...e,anchor:a});return W(!1),h}catch(h){if(h instanceof Error&&h.message.split(` +`).some(_=>_.startsWith("https://svelte.dev/e/")))throw h;return h!==ne&&console.warn("Failed to hydrate: ",h),e.recover===!1&&tt(),ae(),rt(t),W(!1),vt(r,e)}finally{W(i),K(o)}}const U=new Map;function Oe(r,{target:e,anchor:t,props:i={},events:o,context:a,intro:h=!0,transformError:_}){ae();var l=void 0,c=st(()=>{var H=t??e.appendChild(Te());ct(H,{pending:()=>{}},v=>{at({});var u=Se;if(a&&(u.c=a),o&&(i.$$events=o),I&&ot(v,null),l=r(v,i)||{},I&&(P.nodes.end=Y,Y===null||Y.nodeType!==Re||Y.data!==nt))throw ht(),ne;ft()},_);var $=new Set,N=v=>{for(var u=0;u{var O;for(var v of $)for(const m of[e,document]){var u=U.get(m),y=u.get(v);--y==0?(m.removeEventListener(v,me),u.delete(v),u.size===0&&U.delete(m)):u.set(v,y)}ve.delete(N),H!==t&&((O=H.parentNode)==null||O.removeChild(H))}});return oe.set(l,c),l}let oe=new WeakMap;function wt(r,e){const t=oe.get(r);return t?(oe.delete(r),t(e)):Promise.resolve()}export{Tt as h,vt as m,Et as s,wt as u}; diff --git a/web/build/_app/immutable/chunks/YzYuob9f.js b/web/build/_app/immutable/chunks/YzYuob9f.js new file mode 100644 index 0000000..4817e37 --- /dev/null +++ b/web/build/_app/immutable/chunks/YzYuob9f.js @@ -0,0 +1 @@ +var Xt=t=>{throw TypeError(t)};var Pe=(t,e,n)=>e.has(t)||Xt("Cannot "+n);var w=(t,e,n)=>(Pe(t,e,"read from private field"),n?n.call(t):e.get(t)),A=(t,e,n)=>e.has(t)?Xt("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(t):e.set(t,n);import{aL as T,g as I,e as P,aK as pt,aN as Oe}from"./CUCwB180.js";import{w as jt,o as Qt}from"./DNqN6DmX.js";class Nt{constructor(e,n){this.status=e,typeof n=="string"?this.body={message:n}:n?this.body=n:this.body={message:`Error: ${e}`}}toString(){return JSON.stringify(this.body)}}class Dt{constructor(e,n){this.status=e,this.location=n}}class Vt extends Error{constructor(e,n,r){super(r),this.status=e,this.text=n}}new URL("sveltekit-internal://");function $e(t,e){return t==="/"||e==="ignore"?t:e==="never"?t.endsWith("/")?t.slice(0,-1):t:e==="always"&&!t.endsWith("/")?t+"/":t}function Ce(t){return t.split("%25").map(decodeURI).join("%25")}function je(t){for(const e in t)t[e]=decodeURIComponent(t[e]);return t}function Lt({href:t}){return t.split("#")[0]}function Ne(...t){let e=5381;for(const n of t)if(typeof n=="string"){let r=n.length;for(;r;)e=e*33^n.charCodeAt(--r)}else if(ArrayBuffer.isView(n)){const r=new Uint8Array(n.buffer,n.byteOffset,n.byteLength);let a=r.length;for(;a;)e=e*33^r[--a]}else throw new TypeError("value must be a string or TypedArray");return(e>>>0).toString(36)}new TextEncoder;new TextDecoder;function De(t){const e=atob(t),n=new Uint8Array(e.length);for(let r=0;r((t instanceof Request?t.method:(e==null?void 0:e.method)||"GET")!=="GET"&&Y.delete(qt(t)),Ve(t,e));const Y=new Map;function qe(t,e){const n=qt(t,e),r=document.querySelector(n);if(r!=null&&r.textContent){r.remove();let{body:a,...s}=JSON.parse(r.textContent);const o=r.getAttribute("data-ttl");return o&&Y.set(n,{body:a,init:s,ttl:1e3*Number(o)}),r.getAttribute("data-b64")!==null&&(a=De(a)),Promise.resolve(new Response(a,s))}return window.fetch(t,e)}function Ke(t,e,n){if(Y.size>0){const r=qt(t,n),a=Y.get(r);if(a){if(performance.now(){const a=/^\[\.\.\.(\w+)(?:=(\w+))?\]$/.exec(r);if(a)return e.push({name:a[1],matcher:a[2],optional:!1,rest:!0,chained:!0}),"(?:/([^]*))?";const s=/^\[\[(\w+)(?:=(\w+))?\]\]$/.exec(r);if(s)return e.push({name:s[1],matcher:s[2],optional:!0,rest:!1,chained:!0}),"(?:/([^/]+))?";if(!r)return;const o=r.split(/\[(.+?)\](?!\])/);return"/"+o.map((l,c)=>{if(c%2){if(l.startsWith("x+"))return Ut(String.fromCharCode(parseInt(l.slice(2),16)));if(l.startsWith("u+"))return Ut(String.fromCharCode(...l.slice(2).split("-").map(_=>parseInt(_,16))));const d=Be.exec(l),[,u,v,f,h]=d;return e.push({name:f,matcher:h,optional:!!u,rest:!!v,chained:v?c===1&&o[0]==="":!1}),v?"([^]*?)":u?"([^/]*)?":"([^/]+?)"}return Ut(l)}).join("")}).join("")}/?$`),params:e}}function Fe(t){return t!==""&&!/^\([^)]+\)$/.test(t)}function Ge(t){return t.slice(1).split("/").filter(Fe)}function We(t,e,n){const r={},a=t.slice(1),s=a.filter(i=>i!==void 0);let o=0;for(let i=0;id).join("/"),o=0),c===void 0)if(l.rest)c="";else continue;if(!l.matcher||n[l.matcher](c)){r[l.name]=c;const d=e[i+1],u=a[i+1];d&&!d.rest&&d.optional&&u&&l.chained&&(o=0),!d&&!u&&Object.keys(r).length===s.length&&(o=0);continue}if(l.optional&&l.chained){o++;continue}return}if(!o)return r}function Ut(t){return t.normalize().replace(/[[\]]/g,"\\$&").replace(/%/g,"%25").replace(/\//g,"%2[Ff]").replace(/\?/g,"%3[Ff]").replace(/#/g,"%23").replace(/[.*+?^${}()|\\]/g,"\\$&")}function Ye({nodes:t,server_loads:e,dictionary:n,matchers:r}){const a=new Set(e);return Object.entries(n).map(([i,[l,c,d]])=>{const{pattern:u,params:v}=Me(i),f={id:i,exec:h=>{const _=u.exec(h);if(_)return We(_,v,r)},errors:[1,...d||[]].map(h=>t[h]),layouts:[0,...c||[]].map(o),leaf:s(l)};return f.errors.length=f.layouts.length=Math.max(f.errors.length,f.layouts.length),f});function s(i){const l=i<0;return l&&(i=~i),[l,t[i]]}function o(i){return i===void 0?i:[a.has(i),t[i]]}}function ue(t,e=JSON.parse){try{return e(sessionStorage[t])}catch{}}function Zt(t,e,n=JSON.stringify){const r=n(e);try{sessionStorage[t]=r}catch{}}var se;const U=((se=globalThis.__sveltekit_1smak34)==null?void 0:se.base)??"";var ie;const ze=((ie=globalThis.__sveltekit_1smak34)==null?void 0:ie.assets)??U??"",He="1773071061170",de="sveltekit:snapshot",he="sveltekit:scroll",pe="sveltekit:states",Je="sveltekit:pageurl",F="sveltekit:history",H="sveltekit:navigation",D={tap:1,hover:2,viewport:3,eager:4,off:-1,false:-1},Et=location.origin;function Kt(t){if(t instanceof URL)return t;let e=document.baseURI;if(!e){const n=document.getElementsByTagName("base");e=n.length?n[0].href:document.URL}return new URL(t,e)}function q(){return{x:pageXOffset,y:pageYOffset}}function M(t,e){return t.getAttribute(`data-sveltekit-${e}`)}const te={...D,"":D.hover};function ge(t){let e=t.assignedSlot??t.parentNode;return(e==null?void 0:e.nodeType)===11&&(e=e.host),e}function me(t,e){for(;t&&t!==e;){if(t.nodeName.toUpperCase()==="A"&&t.hasAttribute("href"))return t;t=ge(t)}}function It(t,e,n){let r;try{if(r=new URL(t instanceof SVGAElement?t.href.baseVal:t.href,document.baseURI),n&&r.hash.match(/^#[^/]/)){const i=location.hash.split("#")[1]||"/";r.hash=`#${i}${r.hash}`}}catch{}const a=t instanceof SVGAElement?t.target.baseVal:t.target,s=!r||!!a||St(r,e,n)||(t.getAttribute("rel")||"").split(/\s+/).includes("external"),o=(r==null?void 0:r.origin)===Et&&t.hasAttribute("download");return{url:r,external:s,target:a,download:o}}function gt(t){let e=null,n=null,r=null,a=null,s=null,o=null,i=t;for(;i&&i!==document.documentElement;)r===null&&(r=M(i,"preload-code")),a===null&&(a=M(i,"preload-data")),e===null&&(e=M(i,"keepfocus")),n===null&&(n=M(i,"noscroll")),s===null&&(s=M(i,"reload")),o===null&&(o=M(i,"replacestate")),i=ge(i);function l(c){switch(c){case"":case"true":return!0;case"off":case"false":return!1;default:return}}return{preload_code:te[r??"off"],preload_data:te[a??"off"],keepfocus:l(e),noscroll:l(n),reload:l(s),replace_state:l(o)}}function ee(t){const e=jt(t);let n=!0;function r(){n=!0,e.update(o=>o)}function a(o){n=!1,e.set(o)}function s(o){let i;return e.subscribe(l=>{(i===void 0||n&&l!==i)&&o(i=l)})}return{notify:r,set:a,subscribe:s}}const _e={v:()=>{}};function Xe(){const{set:t,subscribe:e}=jt(!1);let n;async function r(){clearTimeout(n);try{const a=await fetch(`${ze}/_app/version.json`,{headers:{pragma:"no-cache","cache-control":"no-cache"}});if(!a.ok)return!1;const o=(await a.json()).version!==He;return o&&(t(!0),_e.v(),clearTimeout(n)),o}catch{return!1}}return{subscribe:e,check:r}}function St(t,e,n){return t.origin!==Et||!t.pathname.startsWith(e)?!0:n?t.pathname!==location.pathname:!1}function xn(t){}const we=new Set(["load","prerender","csr","ssr","trailingSlash","config"]);[...we];const Qe=new Set([...we]);[...Qe];function Ze(t){return t.filter(e=>e!=null)}function Bt(t){return t instanceof Nt||t instanceof Vt?t.status:500}function tn(t){return t instanceof Vt?t.text:"Internal Error"}let R,J,At;const en=Qt.toString().includes("$$")||/function \w+\(\) \{\}/.test(Qt.toString());var et,nt,at,rt,ot,st,it,lt,le,ct,ce,ft,fe;en?(R={data:{},form:null,error:null,params:{},route:{id:null},state:{},status:-1,url:new URL("https://example.com")},J={current:null},At={current:!1}):(R=new(le=class{constructor(){A(this,et,T({}));A(this,nt,T(null));A(this,at,T(null));A(this,rt,T({}));A(this,ot,T({id:null}));A(this,st,T({}));A(this,it,T(-1));A(this,lt,T(new URL("https://example.com")))}get data(){return I(w(this,et))}set data(e){P(w(this,et),e)}get form(){return I(w(this,nt))}set form(e){P(w(this,nt),e)}get error(){return I(w(this,at))}set error(e){P(w(this,at),e)}get params(){return I(w(this,rt))}set params(e){P(w(this,rt),e)}get route(){return I(w(this,ot))}set route(e){P(w(this,ot),e)}get state(){return I(w(this,st))}set state(e){P(w(this,st),e)}get status(){return I(w(this,it))}set status(e){P(w(this,it),e)}get url(){return I(w(this,lt))}set url(e){P(w(this,lt),e)}},et=new WeakMap,nt=new WeakMap,at=new WeakMap,rt=new WeakMap,ot=new WeakMap,st=new WeakMap,it=new WeakMap,lt=new WeakMap,le),J=new(ce=class{constructor(){A(this,ct,T(null))}get current(){return I(w(this,ct))}set current(e){P(w(this,ct),e)}},ct=new WeakMap,ce),At=new(fe=class{constructor(){A(this,ft,T(!1))}get current(){return I(w(this,ft))}set current(e){P(w(this,ft),e)}},ft=new WeakMap,fe),_e.v=()=>At.current=!0);function nn(t){Object.assign(R,t)}const an=new Set(["icon","shortcut icon","apple-touch-icon"]),j=ue(he)??{},X=ue(de)??{},C={url:ee({}),page:ee({}),navigating:jt(null),updated:Xe()};function Mt(t){j[t]=q()}function rn(t,e){let n=t+1;for(;j[n];)delete j[n],n+=1;for(n=e+1;X[n];)delete X[n],n+=1}function Q(t,e=!1){return e?location.replace(t.href):location.href=t.href,new Promise(()=>{})}async function ve(){if("serviceWorker"in navigator){const t=await navigator.serviceWorker.getRegistration(U||"/");t&&await t.update()}}function ne(){}let Ft,Pt,mt,O,Ot,E;const _t=[],wt=[];let y=null;function $t(){var t;(t=y==null?void 0:y.fork)==null||t.then(e=>e==null?void 0:e.discard()),y=null}const ht=new Map,ye=new Set,on=new Set,z=new Set;let m={branch:[],error:null,url:null},be=!1,vt=!1,ae=!0,Z=!1,W=!1,ke=!1,Gt=!1,Ee,k,L,V;const yt=new Set,re=new Map;async function Tn(t,e,n){var s,o,i,l,c;(s=globalThis.__sveltekit_1smak34)!=null&&s.data&&globalThis.__sveltekit_1smak34.data,document.URL!==location.href&&(location.href=location.href),E=t,await((i=(o=t.hooks).init)==null?void 0:i.call(o)),Ft=Ye(t),O=document.documentElement,Ot=e,Pt=t.nodes[0],mt=t.nodes[1],Pt(),mt(),k=(l=history.state)==null?void 0:l[F],L=(c=history.state)==null?void 0:c[H],k||(k=L=Date.now(),history.replaceState({...history.state,[F]:k,[H]:L},""));const r=j[k];function a(){r&&(history.scrollRestoration="manual",scrollTo(r.x,r.y))}n?(a(),await vn(Ot,n)):(await G({type:"enter",url:Kt(E.hash?kn(new URL(location.href)):location.href),replace_state:!0}),a()),wn()}function sn(){_t.length=0,Gt=!1}function Se(t){wt.some(e=>e==null?void 0:e.snapshot)&&(X[t]=wt.map(e=>{var n;return(n=e==null?void 0:e.snapshot)==null?void 0:n.capture()}))}function Re(t){var e;(e=X[t])==null||e.forEach((n,r)=>{var a,s;(s=(a=wt[r])==null?void 0:a.snapshot)==null||s.restore(n)})}function oe(){Mt(k),Zt(he,j),Se(L),Zt(de,X)}async function xe(t,e,n,r){let a;e.invalidateAll&&$t(),await G({type:"goto",url:Kt(t),keepfocus:e.keepFocus,noscroll:e.noScroll,replace_state:e.replaceState,state:e.state,redirect_count:n,nav_token:r,accept:()=>{e.invalidateAll&&(Gt=!0,a=[...re.keys()]),e.invalidate&&e.invalidate.forEach(_n)}}),e.invalidateAll&&pt().then(pt).then(()=>{re.forEach(({resource:s},o)=>{var i;a!=null&&a.includes(o)&&((i=s.refresh)==null||i.call(s))})})}async function ln(t){if(t.id!==(y==null?void 0:y.id)){$t();const e={};yt.add(e),y={id:t.id,token:e,promise:Ue({...t,preload:e}).then(n=>(yt.delete(e),n.type==="loaded"&&n.state.error&&$t(),n)),fork:null}}return y.promise}async function Tt(t){var n;const e=(n=await Rt(t,!1))==null?void 0:n.route;e&&await Promise.all([...e.layouts,e.leaf].filter(Boolean).map(r=>r[1]()))}async function Le(t,e,n){var a;m=t.state;const r=document.querySelector("style[data-sveltekit]");if(r&&r.remove(),Object.assign(R,t.props.page),Ee=new E.root({target:e,props:{...t.props,stores:C,components:wt},hydrate:n,sync:!1}),await Promise.resolve(),Re(L),n){const s={from:null,to:{params:m.params,route:{id:((a=m.route)==null?void 0:a.id)??null},url:new URL(location.href),scroll:j[k]??q()},willUnload:!1,type:"enter",complete:Promise.resolve()};z.forEach(o=>o(s))}vt=!0}function bt({url:t,params:e,branch:n,status:r,error:a,route:s,form:o}){let i="never";if(U&&(t.pathname===U||t.pathname===U+"/"))i="always";else for(const f of n)(f==null?void 0:f.slash)!==void 0&&(i=f.slash);t.pathname=$e(t.pathname,i),t.search=t.search;const l={type:"loaded",state:{url:t,params:e,branch:n,error:a,route:s},props:{constructors:Ze(n).map(f=>f.node.component),page:Jt(R)}};o!==void 0&&(l.props.form=o);let c={},d=!R,u=0;for(let f=0;fi(new URL(o))))return!0;return!1}function Yt(t,e){return(t==null?void 0:t.type)==="data"?t:(t==null?void 0:t.type)==="skip"?e??null:null}function un(t,e){if(!t)return new Set(e.searchParams.keys());const n=new Set([...t.searchParams.keys(),...e.searchParams.keys()]);for(const r of n){const a=t.searchParams.getAll(r),s=e.searchParams.getAll(r);a.every(o=>s.includes(o))&&s.every(o=>a.includes(o))&&n.delete(r)}return n}function dn({error:t,url:e,route:n,params:r}){return{type:"loaded",state:{error:t,url:e,route:n,params:r,branch:[]},props:{page:Jt(R),constructors:[]}}}async function Ue({id:t,invalidating:e,url:n,params:r,route:a,preload:s}){if((y==null?void 0:y.id)===t)return yt.delete(y.token),y.promise;const{errors:o,layouts:i,leaf:l}=a,c=[...i,l];o.forEach(g=>g==null?void 0:g().catch(()=>{})),c.forEach(g=>g==null?void 0:g[1]().catch(()=>{}));const d=m.url?t!==kt(m.url):!1,u=m.route?a.id!==m.route.id:!1,v=un(m.url,n);let f=!1;const h=c.map(async(g,p)=>{var $;if(!g)return;const b=m.branch[p];return g[1]===(b==null?void 0:b.loader)&&!fn(f,u,d,v,($=b.universal)==null?void 0:$.uses,r)?b:(f=!0,Wt({loader:g[1],url:n,params:r,route:a,parent:async()=>{var ut;const N={};for(let K=0;K{});const _=[];for(let g=0;gPromise.resolve({}),server_data_node:Yt(s)}),i={node:await mt(),loader:mt,universal:null,server:null,data:null};return bt({url:n,params:a,branch:[o,i],status:t,error:e,route:null})}catch(o){if(o instanceof Dt)return xe(new URL(o.location,location.href),{},0);throw o}}async function pn(t){const e=t.href;if(ht.has(e))return ht.get(e);let n;try{const r=(async()=>{let a=await E.hooks.reroute({url:new URL(t),fetch:async(s,o)=>cn(s,o,t).promise})??t;if(typeof a=="string"){const s=new URL(t);E.hash?s.hash=a:s.pathname=a,a=s}return a})();ht.set(e,r),n=await r}catch{ht.delete(e);return}return n}async function Rt(t,e){if(t&&!St(t,U,E.hash)){const n=await pn(t);if(!n)return;const r=gn(n);for(const a of Ft){const s=a.exec(r);if(s)return{id:kt(t),invalidating:e,route:a,params:je(s),url:t}}}}function gn(t){return Ce(E.hash?t.hash.replace(/^#/,"").replace(/[?#].+/,""):t.pathname.slice(U.length))||"/"}function kt(t){return(E.hash?t.hash.replace(/^#/,""):t.pathname)+t.search}function Ae({url:t,type:e,intent:n,delta:r,event:a,scroll:s}){let o=!1;const i=Ht(m,n,t,e,s??null);r!==void 0&&(i.navigation.delta=r),a!==void 0&&(i.navigation.event=a);const l={...i.navigation,cancel:()=>{o=!0,i.reject(new Error("navigation cancelled"))}};return Z||ye.forEach(c=>c(l)),o?null:i}async function G({type:t,url:e,popped:n,keepfocus:r,noscroll:a,replace_state:s,state:o={},redirect_count:i=0,nav_token:l={},accept:c=ne,block:d=ne,event:u}){var K;const v=V;V=l;const f=await Rt(e,!1),h=t==="enter"?Ht(m,f,e,t):Ae({url:e,type:t,delta:n==null?void 0:n.delta,intent:f,scroll:n==null?void 0:n.scroll,event:u});if(!h){d(),V===l&&(V=v);return}const _=k,g=L;c(),Z=!0,vt&&h.navigation.type!=="enter"&&C.navigating.set(J.current=h.navigation);let p=f&&await Ue(f);if(!p){if(St(e,U,E.hash))return await Q(e,s);p=await Te(e,{id:null},await tt(new Vt(404,"Not Found",`Not found: ${e.pathname}`),{url:e,params:{},route:{id:null}}),404,s)}if(e=(f==null?void 0:f.url)||e,V!==l)return h.reject(new Error("navigation aborted")),!1;if(p.type==="redirect"){if(i<20){await G({type:t,url:new URL(p.location,e),popped:n,keepfocus:r,noscroll:a,replace_state:s,state:o,redirect_count:i+1,nav_token:l}),h.fulfil(void 0);return}p=await zt({status:500,error:await tt(new Error("Redirect loop"),{url:e,params:{},route:{id:null}}),url:e,route:{id:null}})}else p.props.page.status>=400&&await C.updated.check()&&(await ve(),await Q(e,s));if(sn(),Mt(_),Se(g),p.props.page.url.pathname!==e.pathname&&(e.pathname=p.props.page.url.pathname),o=n?n.state:o,!n){const S=s?0:1,dt={[F]:k+=S,[H]:L+=S,[pe]:o};(s?history.replaceState:history.pushState).call(history,dt,"",e),s||rn(k,L)}const b=f&&(y==null?void 0:y.id)===f.id?y.fork:null;y=null,p.props.page.state=o;let x;if(vt){const S=(await Promise.all(Array.from(on,B=>B(h.navigation)))).filter(B=>typeof B=="function");if(S.length>0){let B=function(){S.forEach(xt=>{z.delete(xt)})};S.push(B),S.forEach(xt=>{z.add(xt)})}m=p.state,p.props.page&&(p.props.page.url=e);const dt=b&&await b;dt?x=dt.commit():(Ee.$set(p.props),nn(p.props.page),x=(K=Oe)==null?void 0:K()),ke=!0}else await Le(p,Ot,!1);const{activeElement:$}=document;await x,await pt(),await pt();let N=null;if(ae){const S=n?n.scroll:a?q():null;S?scrollTo(S.x,S.y):(N=e.hash&&document.getElementById(Ie(e)))?N.scrollIntoView():scrollTo(0,0)}const ut=document.activeElement!==$&&document.activeElement!==document.body;!r&&!ut&&bn(e,!N),ae=!0,p.props.page&&Object.assign(R,p.props.page),Z=!1,t==="popstate"&&Re(L),h.fulfil(void 0),h.navigation.to&&(h.navigation.to.scroll=q()),z.forEach(S=>S(h.navigation)),C.navigating.set(J.current=null)}async function Te(t,e,n,r,a){return t.origin===Et&&t.pathname===location.pathname&&!be?await zt({status:r,error:n,url:t,route:e}):await Q(t,a)}function mn(){let t,e={element:void 0,href:void 0},n;O.addEventListener("mousemove",i=>{const l=i.target;clearTimeout(t),t=setTimeout(()=>{s(l,D.hover)},20)});function r(i){i.defaultPrevented||s(i.composedPath()[0],D.tap)}O.addEventListener("mousedown",r),O.addEventListener("touchstart",r,{passive:!0});const a=new IntersectionObserver(i=>{for(const l of i)l.isIntersecting&&(Tt(new URL(l.target.href)),a.unobserve(l.target))},{threshold:0});async function s(i,l){const c=me(i,O),d=c===e.element&&(c==null?void 0:c.href)===e.href&&l>=n;if(!c||d)return;const{url:u,external:v,download:f}=It(c,U,E.hash);if(v||f)return;const h=gt(c),_=u&&kt(m.url)===kt(u);if(!(h.reload||_))if(l<=h.preload_data){e={element:c,href:c.href},n=D.tap;const g=await Rt(u,!1);if(!g)return;ln(g)}else l<=h.preload_code&&(e={element:c,href:c.href},n=l,Tt(u))}function o(){a.disconnect();for(const i of O.querySelectorAll("a")){const{url:l,external:c,download:d}=It(i,U,E.hash);if(c||d)continue;const u=gt(i);u.reload||(u.preload_code===D.viewport&&a.observe(i),u.preload_code===D.eager&&Tt(l))}}z.add(o),o()}function tt(t,e){if(t instanceof Nt)return t.body;const n=Bt(t),r=tn(t);return E.hooks.handleError({error:t,event:e,status:n,message:r})??{message:r}}function In(t,e={}){return t=new URL(Kt(t)),t.origin!==Et?Promise.reject(new Error("goto: invalid URL")):xe(t,e,0)}function _n(t){if(typeof t=="function")_t.push(t);else{const{href:e}=new URL(t,location.href);_t.push(n=>n.href===e)}}function wn(){var e;history.scrollRestoration="manual",addEventListener("beforeunload",n=>{let r=!1;if(oe(),!Z){const a=Ht(m,void 0,null,"leave"),s={...a.navigation,cancel:()=>{r=!0,a.reject(new Error("navigation cancelled"))}};ye.forEach(o=>o(s))}r?(n.preventDefault(),n.returnValue=""):history.scrollRestoration="auto"}),addEventListener("visibilitychange",()=>{document.visibilityState==="hidden"&&oe()}),(e=navigator.connection)!=null&&e.saveData||mn(),O.addEventListener("click",async n=>{if(n.button||n.which!==1||n.metaKey||n.ctrlKey||n.shiftKey||n.altKey||n.defaultPrevented)return;const r=me(n.composedPath()[0],O);if(!r)return;const{url:a,external:s,target:o,download:i}=It(r,U,E.hash);if(!a)return;if(o==="_parent"||o==="_top"){if(window.parent!==window)return}else if(o&&o!=="_self")return;const l=gt(r);if(!(r instanceof SVGAElement)&&a.protocol!==location.protocol&&!(a.protocol==="https:"||a.protocol==="http:")||i)return;const[d,u]=(E.hash?a.hash.replace(/^#/,""):a.href).split("#"),v=d===Lt(location);if(s||l.reload&&(!v||!u)){Ae({url:a,type:"link",event:n})?Z=!0:n.preventDefault();return}if(u!==void 0&&v){const[,f]=m.url.href.split("#");if(f===u){if(n.preventDefault(),u===""||u==="top"&&r.ownerDocument.getElementById("top")===null)scrollTo({top:0});else{const h=r.ownerDocument.getElementById(decodeURIComponent(u));h&&(h.scrollIntoView(),h.focus())}return}if(W=!0,Mt(k),t(a),!l.replace_state)return;W=!1}n.preventDefault(),await new Promise(f=>{requestAnimationFrame(()=>{setTimeout(f,0)}),setTimeout(f,100)}),await G({type:"link",url:a,keepfocus:l.keepfocus,noscroll:l.noscroll,replace_state:l.replace_state??a.href===location.href,event:n})}),O.addEventListener("submit",n=>{if(n.defaultPrevented)return;const r=HTMLFormElement.prototype.cloneNode.call(n.target),a=n.submitter;if(((a==null?void 0:a.formTarget)||r.target)==="_blank"||((a==null?void 0:a.formMethod)||r.method)!=="get")return;const i=new URL((a==null?void 0:a.hasAttribute("formaction"))&&(a==null?void 0:a.formAction)||r.action);if(St(i,U,!1))return;const l=n.target,c=gt(l);if(c.reload)return;n.preventDefault(),n.stopPropagation();const d=new FormData(l,a);i.search=new URLSearchParams(d).toString(),G({type:"form",url:i,keepfocus:c.keepfocus,noscroll:c.noscroll,replace_state:c.replace_state??i.href===location.href,event:n})}),addEventListener("popstate",async n=>{var r;if(!Ct){if((r=n.state)!=null&&r[F]){const a=n.state[F];if(V={},a===k)return;const s=j[a],o=n.state[pe]??{},i=new URL(n.state[Je]??location.href),l=n.state[H],c=m.url?Lt(location)===Lt(m.url):!1;if(l===L&&(ke||c)){o!==R.state&&(R.state=o),t(i),j[k]=q(),s&&scrollTo(s.x,s.y),k=a;return}const u=a-k;await G({type:"popstate",url:i,popped:{state:o,scroll:s,delta:u},accept:()=>{k=a,L=l},block:()=>{history.go(-u)},nav_token:V,event:n})}else if(!W){const a=new URL(location.href);t(a),E.hash&&location.reload()}}}),addEventListener("hashchange",()=>{W&&(W=!1,history.replaceState({...history.state,[F]:++k,[H]:L},"",location.href))});for(const n of document.querySelectorAll("link"))an.has(n.rel)&&(n.href=n.href);addEventListener("pageshow",n=>{n.persisted&&C.navigating.set(J.current=null)});function t(n){m.url=R.url=n,C.page.set(Jt(R)),C.page.notify()}}async function vn(t,{status:e=200,error:n,node_ids:r,params:a,route:s,server_route:o,data:i,form:l}){be=!0;const c=new URL(location.href);let d;({params:a={},route:s={id:null}}=await Rt(c,!1)||{}),d=Ft.find(({id:f})=>f===s.id);let u,v=!0;try{const f=r.map(async(_,g)=>{const p=i[g];return p!=null&&p.uses&&(p.uses=yn(p.uses)),Wt({loader:E.nodes[_],url:c,params:a,route:s,parent:async()=>{const b={};for(let x=0;x{const i=history.state;Ct=!0,location.replace(new URL(`#${r}`,location.href)),history.replaceState(i,"",t),e&&scrollTo(s,o),Ct=!1})}else{const s=document.body,o=s.getAttribute("tabindex");s.tabIndex=-1,s.focus({preventScroll:!0,focusVisible:!1}),o!==null?s.setAttribute("tabindex",o):s.removeAttribute("tabindex")}const a=getSelection();if(a&&a.type!=="None"){const s=[];for(let o=0;o{if(a.rangeCount===s.length){for(let o=0;o{s=u,o=v});return i.catch(()=>{}),{navigation:{from:{params:t.params,route:{id:((c=t.route)==null?void 0:c.id)??null},url:t.url,scroll:q()},to:n&&{params:(e==null?void 0:e.params)??null,route:{id:((d=e==null?void 0:e.route)==null?void 0:d.id)??null},url:n,scroll:a},willUnload:!e,type:r,complete:i},fulfil:s,reject:o}}function Jt(t){return{data:t.data,error:t.error,form:t.form,params:t.params,route:t.route,state:t.state,status:t.status,url:t.url}}function kn(t){const e=new URL(t);return e.hash=decodeURIComponent(t.hash),e}function Ie(t){let e;if(E.hash){const[,,n]=t.hash.split("#",3);e=n??""}else e=t.hash.slice(1);return decodeURIComponent(e)}export{Tn as a,In as g,xn as l,R as p,C as s}; diff --git a/web/build/_app/immutable/chunks/eiK12uJk.js b/web/build/_app/immutable/chunks/eiK12uJk.js new file mode 100644 index 0000000..3980c58 --- /dev/null +++ b/web/build/_app/immutable/chunks/eiK12uJk.js @@ -0,0 +1 @@ +import{h as d,u as g,i as c,j as m,k as l,l as b,g as p,o as h,q as k}from"./CUCwB180.js";function x(n=!1){const s=d,e=s.l.u;if(!e)return;let r=()=>h(s.s);if(n){let o=0,t={};const _=k(()=>{let i=!1;const a=s.s;for(const f in a)a[f]!==t[f]&&(t[f]=a[f],i=!0);return i&&o++,o});r=()=>p(_)}e.b.length&&g(()=>{u(s,r),l(e.b)}),c(()=>{const o=m(()=>e.m.map(b));return()=>{for(const t of o)typeof t=="function"&&t()}}),e.a.length&&c(()=>{u(s,r),l(e.a)})}function u(n,s){if(n.l.s)for(const e of n.l.s)p(e);s()}export{x as i}; diff --git a/web/build/_app/immutable/chunks/pTMRHjpX.js b/web/build/_app/immutable/chunks/pTMRHjpX.js new file mode 100644 index 0000000..2a6b109 --- /dev/null +++ b/web/build/_app/immutable/chunks/pTMRHjpX.js @@ -0,0 +1 @@ +import{x as h,y as d,z as _,A as l,B as p,T as E,C as g,D as u,E as s,R as y,F as x,G as A,H as M,I as N}from"./CUCwB180.js";var f;const i=((f=globalThis==null?void 0:globalThis.window)==null?void 0:f.trustedTypes)&&globalThis.window.trustedTypes.createPolicy("svelte-trusted-html",{createHTML:t=>t});function b(t){return(i==null?void 0:i.createHTML(t))??t}function L(t){var r=h("template");return r.innerHTML=b(t.replaceAll("","")),r.content}function n(t,r){var e=_;e.nodes===null&&(e.nodes={start:t,end:r,a:null,t:null})}function w(t,r){var e=(r&E)!==0,c=(r&g)!==0,a,m=!t.startsWith("");return()=>{if(u)return n(s,null),s;a===void 0&&(a=L(m?t:""+t),e||(a=l(a)));var o=c||p?document.importNode(a,!0):a.cloneNode(!0);if(e){var T=l(o),v=o.lastChild;n(T,v)}else n(o,o);return o}}function C(t=""){if(!u){var r=d(t+"");return n(r,r),r}var e=s;return e.nodeType!==A?(e.before(e=d()),M(e)):N(e),n(e,e),e}function D(){if(u)return n(s,null),s;var t=document.createDocumentFragment(),r=document.createComment(""),e=d();return t.append(r,e),n(r,e),t}function H(t,r){if(u){var e=_;((e.f&y)===0||e.nodes.end===null)&&(e.nodes.end=s),x();return}t!==null&&t.before(r)}export{H as a,n as b,D as c,w as f,C as t}; diff --git a/web/build/_app/immutable/entry/app.H3SWXino.js b/web/build/_app/immutable/entry/app.H3SWXino.js new file mode 100644 index 0000000..a1fa41e --- /dev/null +++ b/web/build/_app/immutable/entry/app.H3SWXino.js @@ -0,0 +1,2 @@ +const __vite__mapDeps=(i,m=__vite__mapDeps,d=(m.f||(m.f=["../nodes/0.D7-FTC_1.js","../chunks/Bzak7iHL.js","../chunks/CAYgxOZ1.js","../chunks/CUCwB180.js","../chunks/DNqN6DmX.js","../chunks/C5YqYP7P.js","../chunks/pTMRHjpX.js","../chunks/eiK12uJk.js","../chunks/YzYuob9f.js","../chunks/CGowzvwH.js","../nodes/1.BZR9od8C.js","../chunks/Db9w--lA.js","../nodes/2.Zg4cVoEY.js","../nodes/3.BjNotmmr.js","../nodes/4.D0WiVz9B.js","../nodes/5.B-zruxZU.js","../nodes/6.Cniq5yLG.js","../nodes/7.BjdHyPf8.js","../nodes/8.Chqc1uyl.js"])))=>i.map(i=>d[i]); +var be=Object.defineProperty;var $=t=>{throw TypeError(t)};var Re=(t,e,r)=>e in t?be(t,e,{enumerable:!0,configurable:!0,writable:!0,value:r}):t[e]=r;var ee=(t,e,r)=>Re(t,typeof e!="symbol"?e+"":e,r),te=(t,e,r)=>e.has(t)||$("Cannot "+r);var a=(t,e,r)=>(te(t,e,"read from private field"),r?r.call(t):e.get(t)),O=(t,e,r)=>e.has(t)?$("Cannot add the same private member more than once"):e instanceof WeakSet?e.add(t):e.set(t,r),M=(t,e,r,i)=>(te(t,e,"write to private field"),i?i.call(t,r):e.set(t,r),r);import{as as Se,a7 as Y,V as Ae,y as re,U as se,W as Oe,D as C,E as Z,X as Ie,at as Te,F as ie,S as oe,ae as ce,au as fe,a8 as ue,H as le,al as U,ai as we,av as Le,M as De,j as de,P as pe,aw as he,ax as ke,ay as xe,az as Ce,g as b,aA as Ve,e as x,aB as je,z as Be,aC as Me,aD as Fe,K as Ue,aE as qe,aF as Ne,q as Ye,aG as ze,aH as He,aI as _e,aJ as Ge,d as Ke,m as We,p as Je,u as Xe,i as Ze,aK as Qe,f as F,s as $e,a as et,aL as z,c as tt,r as rt,t as st,aM as H}from"../chunks/CUCwB180.js";import{h as at,m as nt,u as it,s as ot}from"../chunks/Db9w--lA.js";import"../chunks/Bzak7iHL.js";import{o as ct}from"../chunks/DNqN6DmX.js";import{a as p,f as me,c as G,t as ft}from"../chunks/pTMRHjpX.js";import{c as ut}from"../chunks/C5YqYP7P.js";var S,I,P,D,V,j,q;class ve{constructor(e,r=!0){ee(this,"anchor");O(this,S,new Map);O(this,I,new Map);O(this,P,new Map);O(this,D,new Set);O(this,V,!0);O(this,j,e=>{if(a(this,S).has(e)){var r=a(this,S).get(e),i=a(this,I).get(r);if(i)Se(i),a(this,D).delete(r);else{var c=a(this,P).get(r);c&&(a(this,I).set(r,c.effect),a(this,P).delete(r),c.fragment.lastChild.remove(),this.anchor.before(c.fragment),i=c.effect)}for(const[n,o]of a(this,S)){if(a(this,S).delete(n),n===e)break;const s=a(this,P).get(o);s&&(Y(s.effect),a(this,P).delete(o))}for(const[n,o]of a(this,I)){if(n===r||a(this,D).has(n))continue;const s=()=>{if(Array.from(a(this,S).values()).includes(n)){var h=document.createDocumentFragment();Ie(o,h),h.append(re()),a(this,P).set(n,{effect:o,fragment:h})}else Y(o);a(this,D).delete(n),a(this,I).delete(n)};a(this,V)||!i?(a(this,D).add(n),Ae(o,s,!1)):s()}}});O(this,q,e=>{a(this,S).delete(e);const r=Array.from(a(this,S).values());for(const[i,c]of a(this,P))r.includes(i)||(Y(c.effect),a(this,P).delete(i))});this.anchor=e,M(this,V,r)}ensure(e,r){var i=Oe,c=Te();if(r&&!a(this,I).has(e)&&!a(this,P).has(e))if(c){var n=document.createDocumentFragment(),o=re();n.append(o),a(this,P).set(e,{effect:se(()=>r(o)),fragment:n})}else a(this,I).set(e,se(()=>r(this.anchor)));if(a(this,S).set(i,e),c){for(const[s,f]of a(this,I))s===e?i.unskip_effect(f):i.skip_effect(f);for(const[s,f]of a(this,P))s===e?i.unskip_effect(f.effect):i.skip_effect(f.effect);i.oncommit(a(this,j)),i.ondiscard(a(this,q))}else C&&(this.anchor=Z),a(this,j).call(this,i)}}S=new WeakMap,I=new WeakMap,P=new WeakMap,D=new WeakMap,V=new WeakMap,j=new WeakMap,q=new WeakMap;function K(t,e,r=!1){var i;C&&(i=Z,ie());var c=new ve(t),n=r?ce:0;function o(s,f){if(C){var h=fe(i);if(s!==parseInt(h.substring(1))){var u=ue();le(u),c.anchor=u,U(!1),c.ensure(s,f),U(!0);return}}c.ensure(s,f)}oe(()=>{var s=!1;e((f,h=0)=>{s=!0,o(h,f)}),s||o(-1,null)},n)}function W(t,e,r){var i;C&&(i=Z,ie());var c=new ve(t);oe(()=>{var n=e()??null;if(C){var o=fe(i),s=o===we,f=n!==null;if(s!==f){var h=ue();le(h),c.anchor=h,U(!1),c.ensure(n,n&&(u=>r(u,n))),U(!0);return}}c.ensure(n,n&&(u=>r(u,n)))},ce)}function ae(t,e){return t===e||(t==null?void 0:t[he])===e}function J(t={},e,r,i){return Le(()=>{var c,n;return De(()=>{c=n,n=[],de(()=>{t!==r(...n)&&(e(t,...n),c&&ae(r(...c),t)&&e(null,...c))})}),()=>{pe(()=>{n&&ae(r(...n),t)&&e(null,...n)})}}),t}function X(t,e,r,i){var A;var c=!Ue||(r&qe)!==0,n=(r&Fe)!==0,o=(r&He)!==0,s=i,f=!0,h=()=>(f&&(f=!1,s=o?de(i):i),s);let u;if(n){var R=he in t||_e in t;u=((A=ke(t,e))==null?void 0:A.set)??(R&&e in t?l=>t[e]=l:void 0)}var g,k=!1;n?[g,k]=ut(()=>t[e]):g=t[e],g===void 0&&i!==void 0&&(g=h(),u&&(c&&xe(),u(g)));var _;if(c?_=()=>{var l=t[e];return l===void 0?h():(f=!0,l)}:_=()=>{var l=t[e];return l!==void 0&&(s=void 0),l===void 0?s:l},c&&(r&Ce)===0)return _;if(u){var d=t.$$legacy;return(function(l,v){return arguments.length>0?((!c||!v||d||k)&&u(v?_():l),l):_()})}var m=!1,E=((r&Ne)!==0?Ye:ze)(()=>(m=!1,_()));n&&b(E);var L=Be;return(function(l,v){if(arguments.length>0){const N=v?b(E):c&&n?Ve(l):l;return x(E,N),m=!0,s!==void 0&&(s=N),l}return je&&m||(L.f&Me)!==0?E.v:b(E)})}function lt(t){return class extends dt{constructor(e){super({component:t,...e})}}}var w,y;class dt{constructor(e){O(this,w);O(this,y);var n;var r=new Map,i=(o,s)=>{var f=We(s,!1,!1);return r.set(o,f),f};const c=new Proxy({...e.props||{},$$events:{}},{get(o,s){return b(r.get(s)??i(s,Reflect.get(o,s)))},has(o,s){return s===_e?!0:(b(r.get(s)??i(s,Reflect.get(o,s))),Reflect.has(o,s))},set(o,s,f){return x(r.get(s)??i(s,f),f),Reflect.set(o,s,f)}});M(this,y,(e.hydrate?at:nt)(e.component,{target:e.target,anchor:e.anchor,props:c,context:e.context,intro:e.intro??!1,recover:e.recover,transformError:e.transformError})),(!((n=e==null?void 0:e.props)!=null&&n.$$host)||e.sync===!1)&&Ge(),M(this,w,c.$$events);for(const o of Object.keys(a(this,y)))o==="$set"||o==="$destroy"||o==="$on"||Ke(this,o,{get(){return a(this,y)[o]},set(s){a(this,y)[o]=s},enumerable:!0});a(this,y).$set=o=>{Object.assign(c,o)},a(this,y).$destroy=()=>{it(a(this,y))}}$set(e){a(this,y).$set(e)}$on(e,r){a(this,w)[e]=a(this,w)[e]||[];const i=(...c)=>r.call(this,...c);return a(this,w)[e].push(i),()=>{a(this,w)[e]=a(this,w)[e].filter(c=>c!==i)}}$destroy(){a(this,y).$destroy()}}w=new WeakMap,y=new WeakMap;const ht="modulepreload",_t=function(t,e){return new URL(t,e).href},ne={},T=function(e,r,i){let c=Promise.resolve();if(r&&r.length>0){let o=function(u){return Promise.all(u.map(R=>Promise.resolve(R).then(g=>({status:"fulfilled",value:g}),g=>({status:"rejected",reason:g}))))};const s=document.getElementsByTagName("link"),f=document.querySelector("meta[property=csp-nonce]"),h=(f==null?void 0:f.nonce)||(f==null?void 0:f.getAttribute("nonce"));c=o(r.map(u=>{if(u=_t(u,i),u in ne)return;ne[u]=!0;const R=u.endsWith(".css"),g=R?'[rel="stylesheet"]':"";if(!!i)for(let d=s.length-1;d>=0;d--){const m=s[d];if(m.href===u&&(!R||m.rel==="stylesheet"))return}else if(document.querySelector(`link[href="${u}"]${g}`))return;const _=document.createElement("link");if(_.rel=R?"stylesheet":ht,R||(_.as="script"),_.crossOrigin="",_.href=u,h&&_.setAttribute("nonce",h),document.head.appendChild(_),R)return new Promise((d,m)=>{_.addEventListener("load",d),_.addEventListener("error",()=>m(new Error(`Unable to preload CSS for ${u}`)))})}))}function n(o){const s=new Event("vite:preloadError",{cancelable:!0});if(s.payload=o,window.dispatchEvent(s),!s.defaultPrevented)throw o}return c.then(o=>{for(const s of o||[])s.status==="rejected"&&n(s.reason);return e().catch(n)})},It={};var mt=me('
'),vt=me(" ",1);function gt(t,e){Je(e,!0);let r=X(e,"components",23,()=>[]),i=X(e,"data_0",3,null),c=X(e,"data_1",3,null);Xe(()=>e.stores.page.set(e.page)),Ze(()=>{e.stores,e.page,e.constructors,r(),e.form,i(),c(),e.stores.page.notify()});let n=z(!1),o=z(!1),s=z(null);ct(()=>{const d=e.stores.page.subscribe(()=>{b(n)&&(x(o,!0),Qe().then(()=>{x(s,document.title||"untitled page",!0)}))});return x(n,!0),d});const f=H(()=>e.constructors[1]);var h=vt(),u=F(h);{var R=d=>{const m=H(()=>e.constructors[0]);var E=G(),L=F(E);W(L,()=>b(m),(A,l)=>{J(l(A,{get data(){return i()},get form(){return e.form},get params(){return e.page.params},children:(v,N)=>{var Q=G(),Ee=F(Q);W(Ee,()=>b(f),(Pe,ye)=>{J(ye(Pe,{get data(){return c()},get form(){return e.form},get params(){return e.page.params}}),B=>r()[1]=B,()=>{var B;return(B=r())==null?void 0:B[1]})}),p(v,Q)},$$slots:{default:!0}}),v=>r()[0]=v,()=>{var v;return(v=r())==null?void 0:v[0]})}),p(d,E)},g=d=>{const m=H(()=>e.constructors[0]);var E=G(),L=F(E);W(L,()=>b(m),(A,l)=>{J(l(A,{get data(){return i()},get form(){return e.form},get params(){return e.page.params}}),v=>r()[0]=v,()=>{var v;return(v=r())==null?void 0:v[0]})}),p(d,E)};K(u,d=>{e.constructors[1]?d(R):d(g,-1)})}var k=$e(u,2);{var _=d=>{var m=mt(),E=tt(m);{var L=A=>{var l=ft();st(()=>ot(l,b(s))),p(A,l)};K(E,A=>{b(o)&&A(L)})}rt(m),p(d,m)};K(k,d=>{b(n)&&d(_)})}p(t,h),et()}const Tt=lt(gt),wt=[()=>T(()=>import("../nodes/0.D7-FTC_1.js"),__vite__mapDeps([0,1,2,3,4,5,6,7,8,9]),import.meta.url),()=>T(()=>import("../nodes/1.BZR9od8C.js"),__vite__mapDeps([10,1,2,3,11,6,7,8,4]),import.meta.url),()=>T(()=>import("../nodes/2.Zg4cVoEY.js"),__vite__mapDeps([12,1,2,3,4,7,8]),import.meta.url),()=>T(()=>import("../nodes/3.BjNotmmr.js"),__vite__mapDeps([13,1,2,3,6]),import.meta.url),()=>T(()=>import("../nodes/4.D0WiVz9B.js"),__vite__mapDeps([14,1,2,3,5,4,11,6,7,9,8]),import.meta.url),()=>T(()=>import("../nodes/5.B-zruxZU.js"),__vite__mapDeps([15,1,2,3,6]),import.meta.url),()=>T(()=>import("../nodes/6.Cniq5yLG.js"),__vite__mapDeps([16,1,2,3,5,4,11,6,7,9,8]),import.meta.url),()=>T(()=>import("../nodes/7.BjdHyPf8.js"),__vite__mapDeps([17,1,2,3,5,4,11,6,7,9,8]),import.meta.url),()=>T(()=>import("../nodes/8.Chqc1uyl.js"),__vite__mapDeps([18,1,2,3,6]),import.meta.url)],Lt=[],Dt={"/":[2],"/characters":[3],"/characters/[id]":[4],"/groups":[5],"/groups/[id]":[6],"/groups/[id]/settings":[7],"/login":[8]},ge={handleError:(({error:t})=>{console.error(t)}),reroute:(()=>{}),transport:{}},Et=Object.fromEntries(Object.entries(ge.transport).map(([t,e])=>[t,e.decode])),pt=Object.fromEntries(Object.entries(ge.transport).map(([t,e])=>[t,e.encode])),kt=!1,xt=(t,e)=>Et[t](e);export{xt as decode,Et as decoders,Dt as dictionary,pt as encoders,kt as hash,ge as hooks,It as matchers,wt as nodes,Tt as root,Lt as server_loads}; diff --git a/web/build/_app/immutable/entry/start.Pj34kLt-.js b/web/build/_app/immutable/entry/start.Pj34kLt-.js new file mode 100644 index 0000000..625288f --- /dev/null +++ b/web/build/_app/immutable/entry/start.Pj34kLt-.js @@ -0,0 +1 @@ +import{l as o,a as r}from"../chunks/YzYuob9f.js";export{o as load_css,r as start}; diff --git a/web/build/_app/immutable/nodes/0.D7-FTC_1.js b/web/build/_app/immutable/nodes/0.D7-FTC_1.js new file mode 100644 index 0000000..40cc3ca --- /dev/null +++ b/web/build/_app/immutable/nodes/0.D7-FTC_1.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{o as h}from"../chunks/DNqN6DmX.js";import{D as m,F as g,p as y,f as T,a as $}from"../chunks/CUCwB180.js";import{s as _,a as k}from"../chunks/C5YqYP7P.js";import{c as v,a as w}from"../chunks/pTMRHjpX.js";import{i as A}from"../chunks/eiK12uJk.js";import{g as u}from"../chunks/YzYuob9f.js";import{p as O}from"../chunks/CGowzvwH.js";function S(t,e,a,n,o){var f;m&&g();var s=(f=e.$$slots)==null?void 0:f[a],r=!1;s===!0&&(s=e.children,r=!0),s===void 0||s(t,r?()=>n:n)}const d="https://api.campaign-manager.example.com/v1";let i=null;function E(t){i=t}async function P(){const t=await fetch(`${d}/auth/refresh`,{method:"POST",credentials:"include"});return t.ok?(i=(await t.json()).accessToken,!0):(i=null,!1)}async function c(t,e={}){const{skipAuth:a=!1,...n}=e,o=new Headers(n.headers);!a&&i&&o.set("Authorization",`Bearer ${i}`),n.body&&!o.has("Content-Type")&&o.set("Content-Type","application/json");let s=await fetch(`${d}${t}`,{...n,headers:o,credentials:"include"});return s.status===401&&!a&&await P()&&i&&(o.set("Authorization",`Bearer ${i}`),s=await fetch(`${d}${t}`,{...n,headers:o,credentials:"include"})),s}const B={get:(t,e)=>c(t,{...e,method:"GET"}),post:(t,e,a)=>c(t,{...a,method:"POST",body:e?JSON.stringify(e):void 0}),put:(t,e,a)=>c(t,{...a,method:"PUT",body:e?JSON.stringify(e):void 0}),patch:(t,e,a)=>c(t,{...a,method:"PATCH",body:e?JSON.stringify(e):void 0}),delete:(t,e)=>c(t,{...e,method:"DELETE"})};function z(t,e){y(e,!1);const a=()=>k(O,"$page",n),[n,o]=_(),s=["/login"];h(async()=>{if(!s.includes(a().url.pathname))try{const l=await B.post("/auth/refresh",void 0,{skipAuth:!0});if(l.ok){const p=await l.json();E(p.accessToken)}else u("/login")}catch{u("/login")}}),A();var r=v(),f=T(r);S(f,e,"default",{}),w(t,r),$(),o()}export{z as component}; diff --git a/web/build/_app/immutable/nodes/1.BZR9od8C.js b/web/build/_app/immutable/nodes/1.BZR9od8C.js new file mode 100644 index 0000000..8973ffd --- /dev/null +++ b/web/build/_app/immutable/nodes/1.BZR9od8C.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as h,f as g,t as l,a as v,c as e,r as o,s as d}from"../chunks/CUCwB180.js";import{s as p}from"../chunks/Db9w--lA.js";import{a as _,f as x}from"../chunks/pTMRHjpX.js";import{i as $}from"../chunks/eiK12uJk.js";import{s as k,p as m}from"../chunks/YzYuob9f.js";const b={get error(){return m.error},get status(){return m.status}};k.updated.check;const i=b;var E=x("

",1);function C(f,c){h(c,!1),$();var t=E(),r=g(t),n=e(r,!0);o(r);var s=d(r,2),u=e(s,!0);o(s),l(()=>{var a;p(n,i.status),p(u,(a=i.error)==null?void 0:a.message)}),_(f,t),v()}export{C as component}; diff --git a/web/build/_app/immutable/nodes/2.Zg4cVoEY.js b/web/build/_app/immutable/nodes/2.Zg4cVoEY.js new file mode 100644 index 0000000..21157b7 --- /dev/null +++ b/web/build/_app/immutable/nodes/2.Zg4cVoEY.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{o as p}from"../chunks/DNqN6DmX.js";import{p as r,a as t}from"../chunks/CUCwB180.js";import{i as m}from"../chunks/eiK12uJk.js";import{g as a}from"../chunks/YzYuob9f.js";function c(i,o){r(o,!1),p(()=>a("/groups")),m(),t()}export{c as component}; diff --git a/web/build/_app/immutable/nodes/3.BjNotmmr.js b/web/build/_app/immutable/nodes/3.BjNotmmr.js new file mode 100644 index 0000000..2dd2666 --- /dev/null +++ b/web/build/_app/immutable/nodes/3.BjNotmmr.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{a,f as t}from"../chunks/pTMRHjpX.js";var p=t("

Your Characters

");function n(o){var r=p();a(o,r)}export{n as component}; diff --git a/web/build/_app/immutable/nodes/4.D0WiVz9B.js b/web/build/_app/immutable/nodes/4.D0WiVz9B.js new file mode 100644 index 0000000..acc7bfb --- /dev/null +++ b/web/build/_app/immutable/nodes/4.D0WiVz9B.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as c,t as n,a as h,c as g,r as l}from"../chunks/CUCwB180.js";import{s as _,a as $}from"../chunks/C5YqYP7P.js";import{s as d}from"../chunks/Db9w--lA.js";import{a as u,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as C}from"../chunks/CGowzvwH.js";var b=v("

Character Sheet

",1);function z(r,s){i(s,!1);const e=()=>$(C,"$page",p),[p,o]=_();x();var a=b(),t=f(c(a),2),m=g(t);l(t),n(()=>d(m,`Character ID: ${e().params.id??""}`)),u(r,a),h(),o()}export{z as component}; diff --git a/web/build/_app/immutable/nodes/5.B-zruxZU.js b/web/build/_app/immutable/nodes/5.B-zruxZU.js new file mode 100644 index 0000000..45a0b81 --- /dev/null +++ b/web/build/_app/immutable/nodes/5.B-zruxZU.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{a as p,f as a}from"../chunks/pTMRHjpX.js";var t=a("

Your Groups

");function f(o){var r=t();p(o,r)}export{f as component}; diff --git a/web/build/_app/immutable/nodes/6.Cniq5yLG.js b/web/build/_app/immutable/nodes/6.Cniq5yLG.js new file mode 100644 index 0000000..ec08bd4 --- /dev/null +++ b/web/build/_app/immutable/nodes/6.Cniq5yLG.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as n,t as c,a as l,c as g,r as _}from"../chunks/CUCwB180.js";import{s as $,a as h}from"../chunks/C5YqYP7P.js";import{s as u}from"../chunks/Db9w--lA.js";import{a as d,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as D}from"../chunks/CGowzvwH.js";var G=v("

Group Detail

",1);function A(s,r){i(r,!1);const p=()=>h(D,"$page",o),[o,e]=$();x();var a=G(),t=f(n(a),2),m=g(t);_(t),c(()=>u(m,`Group ID: ${p().params.id??""}`)),d(s,a),l(),e()}export{A as component}; diff --git a/web/build/_app/immutable/nodes/7.BjdHyPf8.js b/web/build/_app/immutable/nodes/7.BjdHyPf8.js new file mode 100644 index 0000000..1d3ad95 --- /dev/null +++ b/web/build/_app/immutable/nodes/7.BjdHyPf8.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{p as i,s as f,f as n,t as c,a as g,c as l,r as _}from"../chunks/CUCwB180.js";import{s as $,a as h}from"../chunks/C5YqYP7P.js";import{s as u}from"../chunks/Db9w--lA.js";import{a as d,f as v}from"../chunks/pTMRHjpX.js";import{i as x}from"../chunks/eiK12uJk.js";import{p as G}from"../chunks/CGowzvwH.js";var b=v("

Group Settings

",1);function z(a,r){i(r,!1);const p=()=>h(G,"$page",o),[o,e]=$();x();var t=b(),s=f(n(t),2),m=l(s);_(s),c(()=>u(m,`Group ID: ${p().params.id??""}`)),d(a,t),g(),e()}export{z as component}; diff --git a/web/build/_app/immutable/nodes/8.Chqc1uyl.js b/web/build/_app/immutable/nodes/8.Chqc1uyl.js new file mode 100644 index 0000000..bd2e00d --- /dev/null +++ b/web/build/_app/immutable/nodes/8.Chqc1uyl.js @@ -0,0 +1 @@ +import"../chunks/Bzak7iHL.js";import"../chunks/CAYgxOZ1.js";import{v as r}from"../chunks/CUCwB180.js";import{a as m,f as n}from"../chunks/pTMRHjpX.js";var p=n("

Campaign Manager

Login — coming soon

",1);function g(o){var a=p();r(2),m(o,a)}export{g as component}; diff --git a/web/build/_app/version.json b/web/build/_app/version.json new file mode 100644 index 0000000..61edd9c --- /dev/null +++ b/web/build/_app/version.json @@ -0,0 +1 @@ +{"version":"1773071061170"} \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..ce1e2a8 --- /dev/null +++ b/web/package.json @@ -0,0 +1,22 @@ +{ + "name": "@campaign-manager/web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@sveltejs/kit": "^2.0.0", + "svelte": "^5.0.0", + "@campaign-manager/rulesets": "workspace:*" + }, + "devDependencies": { + "@sveltejs/adapter-static": "^3.0.0", + "@sveltejs/vite-plugin-svelte": "^5.0.0", + "typescript": "^5.0.0", + "vite": "^6.0.0" + } +} diff --git a/web/src/app.html b/web/src/app.html new file mode 100644 index 0000000..81c0052 --- /dev/null +++ b/web/src/app.html @@ -0,0 +1,12 @@ + + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000..9c8fa4d --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,79 @@ +export const BASE_URL = 'https://api.campaign-manager.example.com/v1'; + +// Access token held in memory only — never persisted to localStorage +let _accessToken: string | null = null; + +export function setAccessToken(token: string | null): void { + _accessToken = token; +} + +export function getAccessToken(): string | null { + return _accessToken; +} + +interface RequestOptions extends RequestInit { + skipAuth?: boolean; +} + +async function silentRefresh(): Promise { + // Refresh token is in an HttpOnly cookie; no manual credential needed + const res = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + }); + + if (!res.ok) { + _accessToken = null; + return false; + } + + const data = await res.json() as { accessToken: string }; + _accessToken = data.accessToken; + return true; +} + +async function apiFetch(path: string, options: RequestOptions = {}): Promise { + const { skipAuth = false, ...fetchOptions } = options; + const headers = new Headers(fetchOptions.headers); + + if (!skipAuth && _accessToken) { + headers.set('Authorization', `Bearer ${_accessToken}`); + } + + if (fetchOptions.body && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + let res = await fetch(`${BASE_URL}${path}`, { + ...fetchOptions, + headers, + credentials: 'include', + }); + + if (res.status === 401 && !skipAuth) { + const refreshed = await silentRefresh(); + if (refreshed && _accessToken) { + headers.set('Authorization', `Bearer ${_accessToken}`); + res = await fetch(`${BASE_URL}${path}`, { ...fetchOptions, headers, credentials: 'include' }); + } + } + + return res; +} + +export const api = { + get: (path: string, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'GET' }), + + post: (path: string, body?: unknown, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'POST', body: body ? JSON.stringify(body) : undefined }), + + put: (path: string, body?: unknown, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'PUT', body: body ? JSON.stringify(body) : undefined }), + + patch: (path: string, body?: unknown, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'PATCH', body: body ? JSON.stringify(body) : undefined }), + + delete: (path: string, options?: RequestOptions) => + apiFetch(path, { ...options, method: 'DELETE' }), +}; diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte new file mode 100644 index 0000000..c4cb838 --- /dev/null +++ b/web/src/routes/+layout.svelte @@ -0,0 +1,27 @@ + + + diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte new file mode 100644 index 0000000..1dc7fc3 --- /dev/null +++ b/web/src/routes/+page.svelte @@ -0,0 +1,6 @@ + diff --git a/web/src/routes/characters/+page.svelte b/web/src/routes/characters/+page.svelte new file mode 100644 index 0000000..c2cdc85 --- /dev/null +++ b/web/src/routes/characters/+page.svelte @@ -0,0 +1,6 @@ + + + +

Your Characters

diff --git a/web/src/routes/characters/[id]/+page.svelte b/web/src/routes/characters/[id]/+page.svelte new file mode 100644 index 0000000..5ba2247 --- /dev/null +++ b/web/src/routes/characters/[id]/+page.svelte @@ -0,0 +1,8 @@ + + + +

Character Sheet

+

Character ID: {$page.params.id}

diff --git a/web/src/routes/groups/+page.svelte b/web/src/routes/groups/+page.svelte new file mode 100644 index 0000000..1e58d33 --- /dev/null +++ b/web/src/routes/groups/+page.svelte @@ -0,0 +1,6 @@ + + + +

Your Groups

diff --git a/web/src/routes/groups/[id]/+page.svelte b/web/src/routes/groups/[id]/+page.svelte new file mode 100644 index 0000000..5ee6c98 --- /dev/null +++ b/web/src/routes/groups/[id]/+page.svelte @@ -0,0 +1,8 @@ + + + +

Group Detail

+

Group ID: {$page.params.id}

diff --git a/web/src/routes/groups/[id]/settings/+page.svelte b/web/src/routes/groups/[id]/settings/+page.svelte new file mode 100644 index 0000000..e445939 --- /dev/null +++ b/web/src/routes/groups/[id]/settings/+page.svelte @@ -0,0 +1,8 @@ + + + +

Group Settings

+

Group ID: {$page.params.id}

diff --git a/web/src/routes/login/+page.svelte b/web/src/routes/login/+page.svelte new file mode 100644 index 0000000..bb6b0fc --- /dev/null +++ b/web/src/routes/login/+page.svelte @@ -0,0 +1,7 @@ + + + +

Campaign Manager

+

Login — coming soon

diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..41b4a3a --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1,8 @@ +import adapter from '@sveltejs/adapter-static'; + +/** @type {import('@sveltejs/kit').Config} */ +export default { + kit: { + adapter: adapter({ fallback: '404.html' }), + }, +}; diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..3ff6926 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "strict": true + } +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..2e920e4 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,6 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], +});