release: v1.12.52
This commit is contained in:
parent
ad155f6c5c
commit
586dc3dc3f
3
.gitignore
vendored
3
.gitignore
vendored
@ -37,6 +37,9 @@ APP_ERKLÄRUNG.md
|
||||
config/settings.json
|
||||
config/voice_design_presets.json
|
||||
config/tts_routes.json
|
||||
config/*.db
|
||||
config/*.db-shm
|
||||
config/*.db-wal
|
||||
config/reader_library/
|
||||
logs/*.log
|
||||
logs/*.log.*
|
||||
|
||||
303
CHANGELOG.md
303
CHANGELOG.md
@ -9,6 +9,309 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.12.52] — 2026-06-30
|
||||
|
||||
### Fixed
|
||||
- **Uncaught TypeError in settings.js** — `$('voice-id-input').addEventListener` crashed on load when the Clone section HTML hadn't been injected yet; guarded with `?.`
|
||||
|
||||
---
|
||||
|
||||
## [1.12.51] — 2026-06-30
|
||||
|
||||
### Fixed
|
||||
- **Engines not loading** — `loadLocalContainers()` was called once at module load before the section HTML existed; now called every time the Engines section is shown and on every tab switch (all three tabs: TTS, STT, LLM)
|
||||
|
||||
---
|
||||
|
||||
## [1.12.50] — 2026-06-30
|
||||
|
||||
### Added
|
||||
- **Skeleton loading for engine cards** — the LOCAL section in Engines (TTS / STT / LLM tabs) now shows animated shimmer placeholder cards while querying Docker container status instead of a blank box
|
||||
|
||||
### Performance
|
||||
- **Parallel Docker status checks** — `GET /api/local-containers` now queries all 25 container definitions concurrently via `asyncio.gather + asyncio.to_thread` instead of sequentially; reduces load time from ~25× latency to ~1× (one Docker socket call worth of time)
|
||||
|
||||
---
|
||||
|
||||
## [1.12.49] — 2026-06-30
|
||||
|
||||
### Added
|
||||
- **SQLite persistence for characters and rehearsals** — character library and script rehearsals are now stored in `config/tts_creator.db` (SQLite) instead of browser IndexedDB; data survives browser-cache clears, works across browsers/devices, and is ready for future migration to PostgreSQL/Supabase
|
||||
- New REST API: `GET/POST/PUT/DELETE /api/characters/{id}` and `GET/POST/PUT/DELETE /api/rehearsals/{id}` backed by `core/database.py`
|
||||
- **One-time automatic migration** — on first load, if server DB is empty, existing IndexedDB data is automatically exported and imported to SQLite (characters and rehearsals separately)
|
||||
|
||||
---
|
||||
|
||||
## [1.12.48] — 2026-06-29
|
||||
|
||||
### Added
|
||||
- **Server-side cast persistence** — after every casting chunk and after manual corrections, the full cast (segments + roster) is saved as `Scripts/cast.md` inside the book's server directory (`config/reader_library/<id>/Scripts/`); survives browser-cache clears and works across devices/browsers
|
||||
- **Cross-device restore** — `audiobookOpenCastView` now has a 3-tier restore: (1) in-memory, (2) localStorage, (3) server fetch; whichever fires first wins; server draft is cached into localStorage automatically
|
||||
- New REST endpoints: `GET/PUT/DELETE /api/reader/docs/{id}/scripts/{name}` and `GET /api/reader/docs/{id}/scripts` (list)
|
||||
|
||||
### Fixed
|
||||
- **Casting tab didn't restore draft on click** — the "Casting" sidebar nav item called `showReaderView('cast')` which only shows the panel DOM; now calls `audiobookOpenCastView()` which runs the full restore logic
|
||||
|
||||
---
|
||||
|
||||
## [1.12.47] — 2026-06-29
|
||||
|
||||
### Added
|
||||
- **Skeleton loading UI** — the casting feed and "Characters Found" panel now show animated shimmer skeleton rows while waiting for results instead of a blank gray area
|
||||
- **Version shown instantly** — app version in About page is read from a `<meta>` tag immediately on load (no async delay); server fetch confirms it in the background
|
||||
|
||||
### Fixed
|
||||
- **Progress bar hidden before casting starts** — the 0% bar no longer shows in the initial "Ready to cast" state; it only appears once casting begins
|
||||
- **VERSION file updated** — the server-side version was stuck at 1.12.37; now correctly reports the current version after container restart
|
||||
|
||||
---
|
||||
|
||||
## [1.12.46] — 2026-06-29
|
||||
|
||||
### Added
|
||||
- **Edit pencil in casting profile** — pencil icon in the top-right of the character header banner; clicking it enables inline editing of all character sheet fields (appearance, personality, history, skills, relationships) with 900ms auto-save; clicking the checkmark exits edit mode
|
||||
- **Sources in casting profile** — "Quellen im Text" section now appears at the bottom of the character profile; clicking a source page jumps to that page in the Read Aloud reader
|
||||
|
||||
### Fixed
|
||||
- **Progress bar hidden after casting** — the yellow progress bar and passage counter disappear automatically when the casting run is complete
|
||||
- **Profil button toggles** — clicking "Profil" when the profile is already open closes it (returns to script); the button label flips between "Profil" and "← Skript"
|
||||
|
||||
---
|
||||
|
||||
## [1.12.45] — 2026-06-29
|
||||
|
||||
### Changed
|
||||
- **Casting character bar** — clicking a character in the casting view's character list no longer hides the feed; instead a compact bar appears above the feed showing the character's avatar, name, and dialogue-line count
|
||||
- All dialogue rows for the selected character are highlighted in the feed (subtle purple tint)
|
||||
- **Prev/next navigation** — up/down buttons in the bar scroll the feed to the previous or next line for that character, with a "3 / 76" position counter
|
||||
- **Search within character lines** — typing in the search box filters to matching lines and scrolls to the first hit; pressing Enter advances to the next match
|
||||
- **Open profile button** — the "Profil" button in the bar hides the feed and opens the full character sheet (voice assignment, alignment, history, etc.); the back button restores the feed and re-applies highlights
|
||||
- Re-clicking the same character in the list closes the bar
|
||||
|
||||
---
|
||||
|
||||
## [1.12.44] — 2026-06-29
|
||||
|
||||
### Added
|
||||
- **Character detail in the casting view** — the "Characters Found" list in the Read Aloud casting panel now shows colored letter-circle avatars (matching the Library view colors) instead of small pill chips; clicking any character hides the cast feed and opens their full character detail (header, voice assignment, alignment, appearance, personality, history, skills, relationships) in the feed area; a "← Zurück zum Skript" button at the top restores the feed
|
||||
- **Voice assignment from casting panel** — Auswählen, Automatisch, and Online suchen voice buttons work inline in the character detail panel; after assigning, the panel refreshes with the new voice
|
||||
|
||||
---
|
||||
|
||||
## [1.12.43] — 2026-06-29
|
||||
|
||||
### Fixed
|
||||
- **Character detail page scrolls properly** — `.lib-char-page` now has a fixed viewport height (`100dvh - 148px`) so the main content panel and the character sidebar each scroll independently; sidebar no longer floats outside or breaks sticky
|
||||
- **Sidebar clearly separated** — 3 px border + inset box-shadow replaces the thin 1 px line; sidebar is now 240 px wide with more padding
|
||||
- **Source quote → reader navigation always works** — clicking a source now always navigates to `s-reader` first; if the book is already open it scrolls to that page div (or finds the first sentence on that page via `readerJumpTo`); only shows a "reopen book" toast if neither source is available
|
||||
- **Auto-assign voice respects German gender terms** — gender detection now handles both English (`female`/`male`) and German (`weiblich`/`männlich`) by checking `startsWith('w')` as female in addition to `startsWith('f')`, preventing female voices from being assigned to male characters whose gender field was written in German
|
||||
|
||||
---
|
||||
|
||||
## [1.12.42] — 2026-06-29
|
||||
|
||||
### Changed
|
||||
- **Character detail is now a full page, not a popup** — clicking a character card opens a two-panel detail page inside the Library section; center pane shows the full character sheet, right sidebar lists all characters in the same production sorted by appearances; "← Alle Charaktere" back button returns to the card grid
|
||||
- **All character fields are inline-editable** — every text field (name, aliases, archetype, physical, backstory, relationships, etc.) is `contenteditable` with auto-save (900 ms debounce after each keystroke) via `clPut`; the moral alignment bar is now a draggable `<input type="range">` slider
|
||||
- **Source quotes in character detail are clickable** — clicking a "Quellen im Text" source entry navigates to that page in Read Aloud (scrolls the reader to that page div); shows a toast if the book is not open yet
|
||||
|
||||
---
|
||||
|
||||
## [1.12.41] — 2026-06-29
|
||||
|
||||
### Changed
|
||||
- **Portrait-style character cards** — cards are now vertical with a colored gradient banner at the top holding the 72 px avatar; name + archetype centered below; snippet, alignment bar, and relationship dots fill the body; compact voice row with "Auswahl" / "Auto" at the bottom; grid uses `minmax(200px, 1fr)` so more fit per row
|
||||
- **Voice assignment moved to top of character detail modal** — the "Stimme" row is now the first thing below the header, so voice picking is always visible without scrolling
|
||||
- **Quellen (sources) section added at bottom of detail modal** — shows verbatim text quotes with page number and context label from the character sheet generation; gives evidence for each character detail
|
||||
- **Stronger German language enforcement for character sheet generation** — system prompt now starts with a bilingual ⚠️ SPRACHE / LANGUAGE block listing every field that must be in German; the user-message reminder also now explicitly says no field may be in English
|
||||
|
||||
### Fixed
|
||||
- Existing character data generated in English can be refreshed by re-running "Cast Characters" from the production header — the language instruction is now strong enough that even stubborn models should comply
|
||||
|
||||
---
|
||||
|
||||
## [1.12.40] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Character detail modal had transparent background** — `var(--card)` is not defined; replaced with `var(--surface)` and `var(--panel)` throughout the modal
|
||||
|
||||
### Changed
|
||||
- **Larger fonts and spacing in character detail modal** — field values 15 px, field labels 11 px, section labels 11 px bold; alignment bar 12 px tall; avatar 90 px; alternating panel/surface backgrounds for section rows give clear visual separation
|
||||
- **Character detail modal fully translated to German** — all labels, section headings, tier names, arc descriptions, alignment descriptions, and button text are now in German (Erscheinung, Persönlichkeit, Geschichte, Fähigkeiten, Beziehungen, Konflikt & Strategie, Geheimnisse & Bogen, Moralische Gesinnung, Stimme zuweisen, etc.)
|
||||
|
||||
---
|
||||
|
||||
## [1.12.39] — 2026-06-28
|
||||
|
||||
### Changed
|
||||
- **Character cards enlarged** — cards are wider (320 px min), font sizes match the rest of the app (15 px name, 13 px body), avatar is 52 px, interaction dots and alignment bar slightly bigger
|
||||
- **Click a card → full D&D/LARP character sheet modal** — shows all generated fields in labelled sections: Appearance (physical + clothing), Personality (mannerisms + voice pattern), Story (backstory, motivation, fears), Skills & Abilities, Relationships (full text + interaction dots), Conflict & Strategy, Secrets & Arc; moral alignment bar is larger with score and arc label; voice assignment buttons live in the modal too; Edit and Close buttons in the header; clicking the avatar inside the modal uploads a profile picture
|
||||
|
||||
---
|
||||
|
||||
## [1.12.38] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Characters/Cast showed empty (only "Import" button) after character sheet generation** — `_charRelsHtml` called `.toLowerCase()` on the `relationships` field which the LLM sometimes returns as an array instead of a string, crashing the card renderer mid-`forEach`. Only the toolbar bar was appended; all production groups were silently lost. Added `_libStr()` coercion in `library.js` for all rendered fields, and `_clSanitize()` in `characters-library.js` applied at both first-insert and merge time so stored data is always clean strings going forward.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.37] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Progress dialog stuck showing "Cancel" after completion** — `.audiobook-overlay { display: flex }` in CSS overrides the `hidden` attribute, so `done()` calling `ov.hidden = true` had no effect. Fixed by using `ov.style.display = 'none'` / `'flex'` directly, same pattern as the cast panel fix.
|
||||
|
||||
### Changed
|
||||
- **Character sheets now navigates to Library → Characters/Cast on completion** — instead of opening a read-only overlay that requires closing, finishing character sheet generation saves all sheets to the library and immediately takes you to Library → Characters / Cast where you can see the cards with alignment bars, interaction dots, and profile pictures. If results are already cached, clicking "Character sheets" again goes straight there too.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.36b] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Character sheet descriptions generated in English for German books** — the language instruction was a weak one-liner buried after a long English system prompt, so the LLM defaulted to English. Now the language rule is the very first thing in the system prompt, explicit about every descriptive field ("write all values in {language}"), and also repeated as a short reminder in the user message directly before the passage. Requires **docker restart** (Python change). Cache the new sheets by re-running "Character sheets" — cached results from earlier runs won't change.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.36] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Profile picture on character sheets** — each character card in the overlay and in Library → Characters/Cast now has an avatar area. Click it to upload any image (PNG/JPG/WebP). The image is stored as a base64 data URL in the IndexedDB character record (`image` field) and survives re-generation. The letter-avatar falls back when no image is uploaded. Overlay cards show an 80×80 avatar beside the name; library cards replace the coloured initial dot with the photo.
|
||||
|
||||
### Fixed
|
||||
- **"Passage N failed: Error"** — the generic error message was caused by the JS not reading the HTTP status or response body before throwing. Now shows `HTTP 502: LLM character-sheet generation failed: <reason>` (or JSON parse error if the server returns non-JSON). Also logs the full error to the console for debugging.
|
||||
- **Context window overflow on early passages** — `csExistingSummary` was listing every missing field for all characters, which grew large quickly and could push the combined prompt over the model's context window. Trimmed to 5 key fields only (physical, backstory, motivation, voice_pattern, relationships).
|
||||
|
||||
---
|
||||
|
||||
## [1.12.35] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Character sheets used wrong mental model** — the generation treated character discovery as its primary job, starting with an empty roster and counting "found" characters as progress. But the 45 characters are already known from the casting run. Now `csForReader` seeds `csGenerate` with `_audiobook.roster` (all known names from the cast) from passage 1. The LLM sees the full cast on every chunk and focuses on filling in details rather than rediscovering. Progress shows **"X / 45 characters detailed"** (how many have at least one filled field) instead of the misleading "1 characters found".
|
||||
|
||||
---
|
||||
|
||||
## [1.12.34] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Alignment bar redesign** — replaced the text-based "◼ Evil → Good ◻" row with a proper gradient box (dark left = evil, light right = good). A white circle with a dark border marks the character's `moral_alignment_score` position. The arc direction arrow (↗ ↘ ↕ →) sits inside the circle and is colour-coded (green = redeeming, orange = descending, purple = complex). Arc note text appears below.
|
||||
- **Character sheet progress: character counter** — the progress overlay now shows a live "X characters found · Y complete" line below the passage progress bar, updating on every passage.
|
||||
- **Image prompt button** on each character card — copies a Stable Diffusion / ComfyUI portrait prompt built from physical description, clothing, archetype, gender, and moral alignment to the clipboard.
|
||||
- **Voice prompt button** on each character card — copies a voice design instruction built from voice_pattern, mannerisms, archetype, gender, and moral tone to the clipboard; ready to paste into Design a Voice.
|
||||
|
||||
### Fixed
|
||||
- **`[object Object]` in Physical / Relationships fields** — when the LLM returned an array or nested object for a scalar field the value was stored as-is and rendered via `String()` as `[object Object]`. Added `_csStr()` sanitiser that joins arrays with ", " and falls back to `JSON.stringify` for any other non-string value; applied at both initial insert and merge in `csMerge`.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.33] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Blue box after "Recast Unknown → no unknown found"** — `audiobookCastView()` set `panel.style.display = 'flex'` as an inline style. When `showReaderView('main')` later set `cast.hidden = true`, the inline style won the specificity battle and the panel remained visible as a large empty box. Fix: clear the inline display style on hide, and stop setting redundant inline flex styles (the `.ab-castpanel-inline` CSS class already defines `display:flex; flex-direction:column`).
|
||||
|
||||
---
|
||||
|
||||
## [1.12.32] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Alignment bar on character cards** — each character card in Library → Characters / Cast now shows a compact good↔evil gradient bar with a white dot positioned at the character's `moral_alignment_score` (0 = evil / black end, 100 = good / light end). An arc arrow beside it shows the character's trajectory: ↗ (redeems), ↘ (descends toward evil), ↕ (complex arc), → (stable).
|
||||
- **Most-interacted characters** — below the alignment bar, up to 5 small coloured avatar dots show which other characters in the same production are most-mentioned in this character's relationships field. Each dot's colour matches that character's avatar and shows name + mention count on hover.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.31] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Read Aloud submenu** — "Read Aloud" in the sidebar is now an expandable tree with two sub-items: **Reader** (PDF view) and **Casting** (jumps directly to the casting panel for the open book).
|
||||
|
||||
### Fixed
|
||||
- **Character sheets "passage 1/1" / No characters found** — `generation.js` (which defines `splitTextIntoChunks`) was missing from the production bundle. When the bundle was active, character sheets always fell back to sending the entire book as a single LLM call, which almost always returned nothing for large texts. `generation.js` is now included in the bundle (21 modules, 701 KB).
|
||||
- **Copy active → Copy selected** — the "Copy active" button on the voice library toolbar now copies the **checked/selected** voices when any rows are checked, and falls back to all active voices only when nothing is selected. Label and toast reflect which mode was used.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.30] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Audiobook casting lost when reopening a book** — the cast autosave was stored under one global key and validated only by a fingerprint of the extracted text. Reopening a large PDF re-extracted text that drifted slightly, the fingerprint mismatched, and the whole cast was discarded as a "different document". Drafts are now keyed by the **book id**, so reopening the same book always restores its cast regardless of text drift (the text fingerprint remains a fallback for ad-hoc documents).
|
||||
- **Opening a book dropped into an empty casting panel** — a leftover cast panel from a previous session hijacked the reader via the nav restore logic. Opening a book now reliably lands on the PDF (main) view; the stale-cast restore is suppressed for a fresh open. Click "Cast as Audiobook" to restore that book's saved cast.
|
||||
|
||||
### Added
|
||||
- **Voice matching from the cast cards** — each character in Library → Characters / Cast now has, beside Pick/Auto, an **Online** button (opens Get a Voice Online on the Fish.audio tab, pre-filled with the character name + detected language) and a **Generate** button (opens Design a Voice pre-filled with the character's gender, language, name, and a voice description built from the sheet).
|
||||
- **Automatic language detection** — a dependency-free `detectLang()` (script ranges + stop-word frequency for de/en/fr/es/it/pt/nl) auto-fills the document language during character extraction when left on Auto, and drives the language pre-fill for online search / voice design.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.28] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- **SillyTavern character-card compatibility** — import and export characters in the SillyTavern / TavernAI "Character Card" format.
|
||||
- **Import** accepts V1 (flat JSON), V2 (`chara_card_v2`), and V3 (`chara_card_v3`) cards as `.json`, plus `.png` cards with the JSON embedded in a PNG `tEXt` chunk (`chara`/`ccv3`). A gender heuristic fills the field when the card omits it. Available globally and per-production in Library → Characters / Cast.
|
||||
- **Export** writes a V2 JSON card per character (universally accepted by SillyTavern, Chub, AI Character Editor, …). The assigned TTS voice is stored under `data.extensions.tts_voice` so a round-trip preserves the casting.
|
||||
|
||||
### Fixed
|
||||
- **Huge empty box / content pushed to the middle** — when the voice API was slow and the 8-second loader fallback fired, it hid the loading skeleton with `opacity:0` but left the element in the DOM (still occupying ~700px) and caused later cleanup to skip removal. The skeleton is now fully removed from the DOM in every path, and `.sk-hidden` also sets `display:none`.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.26] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Sidebar blue highlights on wrong items** — "Language Models", "Books", "Stage" were hardcoded `is-active` in HTML and stayed blue even when on a different section. Added CSS rule `[data-nav-section]:not(.active) + .nav-tree .nav-tree-item.is-active` to suppress sub-item highlight whenever the parent section-head is not current.
|
||||
|
||||
### Added
|
||||
- **Characters / Cast page** — Library → Characters / Cast is now a dedicated production-grouped view instead of redirecting to the generic Characters section. Shows all books/scripts that have characters, with each character displayed as a card containing their character sheet info (archetype, personality snippet), tier badge (Main/Supporting), gender icon, and a voice assignment row with Pick and Auto buttons.
|
||||
- **Voice picker in character cards** — "Pick" opens a compact inline popup with searchable voice list filtered by character gender. "Auto" assigns the highest-rated unassigned matching-gender voice automatically.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.25] — 2026-06-28
|
||||
|
||||
### Added
|
||||
- **Library skeleton loading** — Books and Theater Plays tabs now show animated placeholder cards while data loads instead of a blank panel.
|
||||
- **Cast Characters button** in audiobook completion footer — triggers character sheet generation from the casting panel.
|
||||
- **Characters/Cast nav** in Library now navigates directly to the Characters section.
|
||||
- **Return to cast audiobook** — navigating back to Read Aloud via the sidebar now restores the active casting panel if a cast session was in progress.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.24] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Infinite skeleton hang** — if any script failed to load (`ERR_NETWORK_CHANGED`, DNS failure, etc.) the async loader threw, skeleton-removal code never ran, and the UI was stuck for the entire browser session. `init.js` and `nav.js` are now wrapped in try-catch; `_revealApp()` always runs. Added an 8-second hard timeout as an absolute fallback.
|
||||
|
||||
### Performance
|
||||
- **Bundle enabled by default** — loader now always tries `main.min.js` first (one request instead of 21 individual files). Falls back to per-file loading only if the bundle is missing.
|
||||
- **Eliminated `/api/version` blocking round trip** — version string is read from the already-loaded `style.css` link tag (synchronous, zero latency). Fetching `/api/version` was the first awaited call and blocked the entire load sequence.
|
||||
- **JS cache restored** — JS files now use only the version string for cache-busting (not `version-timestamp`). The session timestamp defeated the service worker cache on every reload; now the SW serves JS from cache on subsequent visits.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.23] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **Reader-main-view bleeds through casting panel** — `#s-reader #reader-main-view { display: flex }` overrode the UA `[hidden]→display:none` rule. When the audiobook cast panel is shown and JS sets `reader-main-view.hidden`, the element stayed rendered (as a 34px tall flex strip) and bled into the cast view. Added `[hidden] { display: none }` specificity override.
|
||||
- **Card collapse chevron invisible** — `.card-chev::before { content: '▾' }` didn't render in all browsers. Switched to `mdi mdi-chevron-down` MDI icon class, consistent with the rest of the UI.
|
||||
- **Sidebar sub-item highlights showing for non-current sections** — added CSS sibling rule so `is-active` sub-items (Stage, Books, Language Models) only show accent colour when their parent section IS the current page.
|
||||
- **Background process indicator** — `window.setNavBusy(sectionId, true|false)` now shows an animated dot on the nav item when Read Aloud is playing.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.22] — 2026-06-28
|
||||
|
||||
### Fixed
|
||||
- **`toggleNavTree is not defined`** — sidebar tree toggles can fire before `nav.js` executes on slow/interrupted loads. An inline stub now handles the click immediately (reads/writes `ttsvc_trees` in localStorage, toggles CSS classes directly); `nav.js` overwrites it with the full implementation once loaded.
|
||||
- **"Saved PDF data missing" dead end** — when a book's source PDF failed to upload at save time (e.g. network interruption), clicking the library card now opens a file picker so the user can re-select the original file. The PDF is re-uploaded to the existing book slot and the book opens immediately.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.21] — 2026-06-28
|
||||
|
||||
### Changed
|
||||
- **Casting feed — progressive expand** — clicking a "N lines hidden" divider now reveals only 20 lines at a time and leaves a new divider for the remainder, so the view stays anchored and the user doesn't lose their place.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.20] — 2026-06-28
|
||||
|
||||
### Performance
|
||||
|
||||
213
core/database.py
Normal file
213
core/database.py
Normal file
@ -0,0 +1,213 @@
|
||||
"""SQLite persistence layer for characters and rehearsals.
|
||||
|
||||
Single-file database at CONFIG_DIR/tts_creator.db.
|
||||
Designed for easy migration to PostgreSQL / Supabase later:
|
||||
- JSON columns map 1-to-1 to JSONB
|
||||
- TEXT PKs (characters) and INTEGER PKs (rehearsals) both survive
|
||||
- No SQLite-specific pragmas that would break compat
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from core.constants import CONFIG_DIR
|
||||
|
||||
_DB_PATH = CONFIG_DIR / "tts_creator.db"
|
||||
|
||||
_DDL = """
|
||||
CREATE TABLE IF NOT EXISTS characters (
|
||||
id TEXT PRIMARY KEY,
|
||||
book TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
tags TEXT NOT NULL DEFAULT '',
|
||||
voice TEXT,
|
||||
image TEXT,
|
||||
sheet TEXT NOT NULL DEFAULT '{}',
|
||||
analysis TEXT,
|
||||
created TEXT,
|
||||
updated TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_characters_book ON characters(book);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS rehearsals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
script TEXT NOT NULL DEFAULT '',
|
||||
cast TEXT NOT NULL DEFAULT '{}',
|
||||
emotions TEXT NOT NULL DEFAULT '{}',
|
||||
notes TEXT NOT NULL DEFAULT '{}',
|
||||
ignored TEXT NOT NULL DEFAULT '{}',
|
||||
hidden_lines TEXT NOT NULL DEFAULT '{}',
|
||||
backend TEXT NOT NULL DEFAULT '',
|
||||
narrator_voice TEXT NOT NULL DEFAULT '',
|
||||
line_index INTEGER NOT NULL DEFAULT 0,
|
||||
created TEXT,
|
||||
updated TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_rehearsals_title ON rehearsals(title);
|
||||
"""
|
||||
|
||||
|
||||
def _open() -> sqlite3.Connection:
|
||||
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(str(_DB_PATH), check_same_thread=False)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def _init() -> None:
|
||||
with _open() as conn:
|
||||
conn.executescript(_DDL)
|
||||
|
||||
|
||||
_init()
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _j(v: Any) -> str:
|
||||
return json.dumps(v, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _row_to_char(row: sqlite3.Row) -> dict:
|
||||
d = dict(row)
|
||||
d["sheet"] = json.loads(d.get("sheet") or "{}")
|
||||
d["analysis"] = json.loads(d.get("analysis") or "null")
|
||||
return d
|
||||
|
||||
|
||||
def _row_to_reh(row: sqlite3.Row) -> dict:
|
||||
d = dict(row)
|
||||
d["cast"] = json.loads(d.get("cast") or "{}")
|
||||
d["emotions"] = json.loads(d.get("emotions") or "{}")
|
||||
d["notes"] = json.loads(d.get("notes") or "{}")
|
||||
d["ignored"] = json.loads(d.get("ignored") or "{}")
|
||||
d["hidden"] = json.loads(d.get("hidden_lines") or "{}")
|
||||
d["narratorVoice"] = d.pop("narrator_voice", "")
|
||||
d["lineIndex"] = d.pop("line_index", 0)
|
||||
d.pop("hidden_lines", None)
|
||||
return d
|
||||
|
||||
|
||||
# ── Characters ────────────────────────────────────────────────────────────────
|
||||
|
||||
def char_get_all() -> list[dict]:
|
||||
with _open() as conn:
|
||||
rows = conn.execute("SELECT * FROM characters ORDER BY book, name").fetchall()
|
||||
return [_row_to_char(r) for r in rows]
|
||||
|
||||
|
||||
def char_get(char_id: str) -> dict | None:
|
||||
with _open() as conn:
|
||||
row = conn.execute("SELECT * FROM characters WHERE id=?", (char_id,)).fetchone()
|
||||
return _row_to_char(row) if row else None
|
||||
|
||||
|
||||
def char_put(rec: dict) -> dict:
|
||||
with _open() as conn:
|
||||
conn.execute("""
|
||||
INSERT INTO characters (id, book, name, tags, voice, image, sheet, analysis, created, updated)
|
||||
VALUES (:id,:book,:name,:tags,:voice,:image,:sheet,:analysis,:created,:updated)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
book=excluded.book, name=excluded.name, tags=excluded.tags,
|
||||
voice=excluded.voice, image=excluded.image, sheet=excluded.sheet,
|
||||
analysis=excluded.analysis, updated=excluded.updated
|
||||
""", {
|
||||
"id": rec.get("id", ""),
|
||||
"book": rec.get("book", ""),
|
||||
"name": rec.get("name", ""),
|
||||
"tags": rec.get("tags", ""),
|
||||
"voice": rec.get("voice"),
|
||||
"image": rec.get("image"),
|
||||
"sheet": _j(rec.get("sheet", {})),
|
||||
"analysis": _j(rec.get("analysis")) if rec.get("analysis") is not None else None,
|
||||
"created": str(rec.get("created", "")),
|
||||
"updated": str(rec.get("updated", "")),
|
||||
})
|
||||
conn.commit()
|
||||
return rec
|
||||
|
||||
|
||||
def char_delete(char_id: str) -> bool:
|
||||
with _open() as conn:
|
||||
conn.execute("DELETE FROM characters WHERE id=?", (char_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
|
||||
|
||||
# ── Rehearsals ────────────────────────────────────────────────────────────────
|
||||
|
||||
def reh_get_all() -> list[dict]:
|
||||
with _open() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM rehearsals ORDER BY updated DESC"
|
||||
).fetchall()
|
||||
return [_row_to_reh(r) for r in rows]
|
||||
|
||||
|
||||
def reh_get(reh_id: int) -> dict | None:
|
||||
with _open() as conn:
|
||||
row = conn.execute("SELECT * FROM rehearsals WHERE id=?", (reh_id,)).fetchone()
|
||||
return _row_to_reh(row) if row else None
|
||||
|
||||
|
||||
def reh_put(rec: dict) -> dict:
|
||||
"""Insert (no id) or full replace (id present). Returns record with id."""
|
||||
reh_id = rec.get("id")
|
||||
row = {
|
||||
"title": rec.get("title", ""),
|
||||
"script": rec.get("script", ""),
|
||||
"cast": _j(rec.get("cast", {})),
|
||||
"emotions": _j(rec.get("emotions", {})),
|
||||
"notes": _j(rec.get("notes", {})),
|
||||
"ignored": _j(rec.get("ignored", {})),
|
||||
"hidden_lines": _j(rec.get("hidden", {})),
|
||||
"backend": rec.get("backend", ""),
|
||||
"narrator_voice": rec.get("narratorVoice", ""),
|
||||
"line_index": int(rec.get("lineIndex", 0)),
|
||||
"created": str(rec.get("created", "")),
|
||||
"updated": str(rec.get("updated", "")),
|
||||
}
|
||||
with _open() as conn:
|
||||
if reh_id:
|
||||
row["id"] = int(reh_id)
|
||||
conn.execute("""
|
||||
INSERT INTO rehearsals
|
||||
(id,title,script,cast,emotions,notes,ignored,hidden_lines,
|
||||
backend,narrator_voice,line_index,created,updated)
|
||||
VALUES
|
||||
(:id,:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines,
|
||||
:backend,:narrator_voice,:line_index,:created,:updated)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title=excluded.title, script=excluded.script, cast=excluded.cast,
|
||||
emotions=excluded.emotions, notes=excluded.notes, ignored=excluded.ignored,
|
||||
hidden_lines=excluded.hidden_lines, backend=excluded.backend,
|
||||
narrator_voice=excluded.narrator_voice, line_index=excluded.line_index,
|
||||
updated=excluded.updated
|
||||
""", row)
|
||||
conn.commit()
|
||||
else:
|
||||
cur = conn.execute("""
|
||||
INSERT INTO rehearsals
|
||||
(title,script,cast,emotions,notes,ignored,hidden_lines,
|
||||
backend,narrator_voice,line_index,created,updated)
|
||||
VALUES
|
||||
(:title,:script,:cast,:emotions,:notes,:ignored,:hidden_lines,
|
||||
:backend,:narrator_voice,:line_index,:created,:updated)
|
||||
""", row)
|
||||
conn.commit()
|
||||
reh_id = cur.lastrowid
|
||||
rec = dict(rec)
|
||||
rec["id"] = reh_id
|
||||
return rec
|
||||
|
||||
|
||||
def reh_delete(reh_id: int) -> bool:
|
||||
with _open() as conn:
|
||||
conn.execute("DELETE FROM rehearsals WHERE id=?", (reh_id,))
|
||||
conn.commit()
|
||||
return True
|
||||
61
routes/characters.py
Normal file
61
routes/characters.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""Character library REST API — backed by SQLite via core.database.
|
||||
|
||||
Mirrors the IndexedDB API in characters-library.js so the JS swap is mechanical:
|
||||
GET /api/characters → { characters: [...] }
|
||||
GET /api/characters/{id} → record | 404
|
||||
PUT /api/characters/{id} → upsert, returns record
|
||||
DELETE /api/characters/{id} → { ok: true }
|
||||
POST /api/characters/migrate → bulk import from JS (one-time migration)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from core.database import char_get_all, char_get, char_put, char_delete
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/characters")
|
||||
async def characters_list():
|
||||
return {"characters": char_get_all()}
|
||||
|
||||
|
||||
@router.get("/api/characters/{char_id:path}")
|
||||
async def characters_get(char_id: str):
|
||||
rec = char_get(char_id)
|
||||
if rec is None:
|
||||
raise HTTPException(404, "Character not found")
|
||||
return rec
|
||||
|
||||
|
||||
@router.put("/api/characters/{char_id:path}")
|
||||
async def characters_put(char_id: str, request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON")
|
||||
body["id"] = char_id
|
||||
return char_put(body)
|
||||
|
||||
|
||||
@router.delete("/api/characters/{char_id:path}")
|
||||
async def characters_delete(char_id: str):
|
||||
char_delete(char_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/characters/migrate")
|
||||
async def characters_migrate(request: Request):
|
||||
"""Accept a batch of character records from the browser's IndexedDB dump."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON")
|
||||
records = body if isinstance(body, list) else body.get("characters", [])
|
||||
n = 0
|
||||
for rec in records:
|
||||
if rec.get("id"):
|
||||
char_put(rec)
|
||||
n += 1
|
||||
return {"imported": n}
|
||||
@ -464,8 +464,18 @@ async def character_sheets(request: Request):
|
||||
if not text:
|
||||
raise HTTPException(400, "No text provided")
|
||||
|
||||
lang_hint = f" The text language is {language}; write the sheet in that language." if language else ""
|
||||
lang_instruction = (
|
||||
f"⚠️ SPRACHE / LANGUAGE — OBLIGATORISCH / MANDATORY:\n"
|
||||
f"The source text is in {language}.\n"
|
||||
f"YOU MUST write EVERY descriptive field value in {language}. This is non-negotiable.\n"
|
||||
f"Fields that MUST be in {language}: physical, clothing, alignment, arc_note, skills, "
|
||||
f"capabilities, backstory, relationships, motivation, fears, mannerisms, voice_pattern, secret, "
|
||||
f"conflict_style, win_condition, archetype, aliases.\n"
|
||||
f"JSON field KEYS stay in English. Character names stay exactly as they appear in the text.\n"
|
||||
f"If you write any descriptive value in English instead of {language}, your response is WRONG.\n\n"
|
||||
) if language else ""
|
||||
system = (
|
||||
f"{lang_instruction}"
|
||||
"You are an expert dramaturge, developmental editor, and tabletop RPG game master building rich "
|
||||
"character sheets passage by passage as a book is read. Extract playable, action-oriented sheets "
|
||||
"an actor can use to immediately know how to PLAY the character.\n"
|
||||
@ -474,7 +484,6 @@ async def character_sheets(request: Request):
|
||||
"blank out a field you cannot improve. Add brand-new characters as they appear. Leave a field empty "
|
||||
"if the book genuinely hasn't shown it yet (a later passage can fill it). Extrapolate from dialogue "
|
||||
"and actions when reasonable, and mark any deduced value with a trailing ' *'.\n"
|
||||
f"{lang_hint}\n"
|
||||
"For each character output these fields:\n"
|
||||
"- name, aliases\n"
|
||||
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
||||
@ -510,9 +519,14 @@ async def character_sheets(request: Request):
|
||||
'"inventory":[],"secret":"","conflict_style":"","win_condition":"",'
|
||||
'"tier":"main","sources":[{"page":1,"quote":"","line_hint":""}]}]}\n/no-think'
|
||||
)
|
||||
lang_reminder = (
|
||||
f"⚠️ WICHTIG: Alle beschreibenden Feldwerte MÜSSEN auf {language} geschrieben werden. "
|
||||
f"Kein einziges beschreibendes Feld darf auf Englisch sein. Nur JSON-Schlüssel bleiben Englisch.\n\n"
|
||||
) if language else ""
|
||||
user = (
|
||||
("Known characters so far: " + ", ".join(str(n) for n in known) + "\n\n" if known else "")
|
||||
+ ("Sheets so far (fill gaps / refine; keep solid facts):\n" + existing + "\n\n" if existing else "")
|
||||
+ lang_reminder
|
||||
+ "Passage:\n" + text
|
||||
)
|
||||
payload: dict = {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
"""Local Docker container management and URL probe endpoints."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
@ -65,15 +66,18 @@ def _container_status(name: str) -> dict:
|
||||
@router.get("/api/local-containers")
|
||||
async def get_local_containers():
|
||||
sock_ok = Path(os.environ.get("DOCKER_SOCKET", "/var/run/docker.sock")).exists()
|
||||
results = []
|
||||
for defn in _LOCAL_CONTAINER_DEFS:
|
||||
|
||||
async def _check(defn: dict) -> dict:
|
||||
entry = {k: v for k, v in defn.items()}
|
||||
if sock_ok:
|
||||
entry.update(_container_status(defn["name"]))
|
||||
status = await asyncio.to_thread(_container_status, defn["name"])
|
||||
entry.update(status)
|
||||
else:
|
||||
entry["status"] = "no_socket"
|
||||
results.append(entry)
|
||||
return {"containers": results, "socket_available": sock_ok}
|
||||
return entry
|
||||
|
||||
results = await asyncio.gather(*[_check(d) for d in _LOCAL_CONTAINER_DEFS])
|
||||
return {"containers": list(results), "socket_available": sock_ok}
|
||||
|
||||
|
||||
@router.post("/api/local-containers/{name}/start")
|
||||
|
||||
@ -189,3 +189,93 @@ async def reader_get_cover(doc_id: str):
|
||||
if not f.is_file():
|
||||
raise HTTPException(404, "Cover not found")
|
||||
return FileResponse(str(f), media_type="image/jpeg", headers={"Cache-Control": "max-age=31536000"})
|
||||
|
||||
|
||||
# ── Scripts (cast save/load) ───────────────────────────────────────────────────
|
||||
# Saves the LLM-cast result (segments + roster) as a Markdown file under
|
||||
# <reader_library>/<doc_id>/Scripts/cast.md
|
||||
# so it survives browser-cache clears and works across devices.
|
||||
|
||||
def _scripts_dir(doc_id: str) -> Path:
|
||||
d = _doc_dir(doc_id) / "Scripts"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def _cast_to_md(data: dict) -> str:
|
||||
"""Serialise cast JSON as a fenced code-block Markdown file."""
|
||||
meta = {k: v for k, v in data.items() if k != "segments"}
|
||||
lines = [
|
||||
"# Cast Script",
|
||||
"",
|
||||
f"**Book:** {data.get('title', '')} ",
|
||||
f"**Saved:** {data.get('savedAt', '')} ",
|
||||
f"**Segments:** {len(data.get('segments', []))} ",
|
||||
"",
|
||||
"```json",
|
||||
json.dumps(data, ensure_ascii=False, indent=2),
|
||||
"```",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
def _md_to_cast(text: str) -> dict:
|
||||
"""Extract the JSON payload from a fenced Markdown cast file."""
|
||||
import re
|
||||
m = re.search(r"```json\s*\n([\s\S]+?)\n```", text)
|
||||
if not m:
|
||||
raise ValueError("No JSON block found in script file")
|
||||
return json.loads(m.group(1))
|
||||
|
||||
|
||||
@router.get("/api/reader/docs/{doc_id}/scripts")
|
||||
async def reader_list_scripts(doc_id: str):
|
||||
sd = _scripts_dir(doc_id)
|
||||
scripts = []
|
||||
for f in sorted(sd.glob("*.md")):
|
||||
try:
|
||||
data = _md_to_cast(f.read_text(encoding="utf-8"))
|
||||
scripts.append({
|
||||
"name": f.stem,
|
||||
"savedAt": data.get("savedAt"),
|
||||
"segments": len(data.get("segments", [])),
|
||||
"roster": data.get("roster", []),
|
||||
})
|
||||
except Exception:
|
||||
scripts.append({"name": f.stem})
|
||||
return scripts
|
||||
|
||||
|
||||
@router.get("/api/reader/docs/{doc_id}/scripts/{name}")
|
||||
async def reader_get_script(doc_id: str, name: str):
|
||||
if not name.replace("-", "").replace("_", "").isalnum():
|
||||
raise HTTPException(400, "Bad script name")
|
||||
f = _scripts_dir(doc_id) / f"{name}.md"
|
||||
if not f.is_file():
|
||||
raise HTTPException(404, "Script not found")
|
||||
try:
|
||||
return _md_to_cast(f.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
raise HTTPException(500, str(e))
|
||||
|
||||
|
||||
@router.put("/api/reader/docs/{doc_id}/scripts/{name}")
|
||||
async def reader_save_script(doc_id: str, name: str, request: Request):
|
||||
if not name.replace("-", "").replace("_", "").isalnum():
|
||||
raise HTTPException(400, "Bad script name")
|
||||
try:
|
||||
data = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON")
|
||||
f = _scripts_dir(doc_id) / f"{name}.md"
|
||||
f.write_text(_cast_to_md(data), encoding="utf-8")
|
||||
return {"saved": str(f.relative_to(_LIB))}
|
||||
|
||||
|
||||
@router.delete("/api/reader/docs/{doc_id}/scripts/{name}")
|
||||
async def reader_delete_script(doc_id: str, name: str):
|
||||
if not name.replace("-", "").replace("_", "").isalnum():
|
||||
raise HTTPException(400, "Bad script name")
|
||||
f = _scripts_dir(doc_id) / f"{name}.md"
|
||||
if f.is_file():
|
||||
f.unlink()
|
||||
return {"deleted": name}
|
||||
|
||||
75
routes/rehearsals_db.py
Normal file
75
routes/rehearsals_db.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Rehearsal library REST API — backed by SQLite via core.database.
|
||||
|
||||
Mirrors the IndexedDB API in rehearser.js:
|
||||
GET /api/rehearsals → { rehearsals: [...] }
|
||||
GET /api/rehearsals/{id} → record | 404
|
||||
PUT /api/rehearsals/{id} → full replace (id must exist)
|
||||
POST /api/rehearsals → insert (no id), returns { id }
|
||||
DELETE /api/rehearsals/{id} → { ok: true }
|
||||
POST /api/rehearsals/migrate → bulk import from JS IndexedDB dump
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from core.database import reh_get_all, reh_get, reh_put, reh_delete
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/api/rehearsals")
|
||||
async def rehearsals_list():
|
||||
return {"rehearsals": reh_get_all()}
|
||||
|
||||
|
||||
@router.get("/api/rehearsals/{reh_id:int}")
|
||||
async def rehearsals_get(reh_id: int):
|
||||
rec = reh_get(reh_id)
|
||||
if rec is None:
|
||||
raise HTTPException(404, "Rehearsal not found")
|
||||
return rec
|
||||
|
||||
|
||||
@router.post("/api/rehearsals")
|
||||
async def rehearsals_add(request: Request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON")
|
||||
body.pop("id", None)
|
||||
saved = reh_put(body)
|
||||
return {"id": saved["id"]}
|
||||
|
||||
|
||||
@router.put("/api/rehearsals/{reh_id:int}")
|
||||
async def rehearsals_put(reh_id: int, request: Request):
|
||||
if reh_get(reh_id) is None:
|
||||
raise HTTPException(404, "Rehearsal not found")
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON")
|
||||
body["id"] = reh_id
|
||||
reh_put(body)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/api/rehearsals/{reh_id:int}")
|
||||
async def rehearsals_delete(reh_id: int):
|
||||
reh_delete(reh_id)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/rehearsals/migrate")
|
||||
async def rehearsals_migrate(request: Request):
|
||||
"""Accept a batch of rehearsal records from the browser's IndexedDB dump."""
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception:
|
||||
raise HTTPException(400, "Invalid JSON")
|
||||
records = body if isinstance(body, list) else body.get("rehearsals", [])
|
||||
ids = []
|
||||
for rec in records:
|
||||
saved = reh_put(rec)
|
||||
ids.append(saved["id"])
|
||||
return {"imported": len(ids), "ids": ids}
|
||||
@ -22,8 +22,8 @@ mkdirSync(outDir, { recursive: true });
|
||||
const MAIN = [
|
||||
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
||||
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||
'characters-library', 'library',
|
||||
'generation', 'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||
'characters-library', 'sillytavern', 'library',
|
||||
].map(n => join(jsDir, n + '.js'));
|
||||
|
||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||
|
||||
@ -13,7 +13,7 @@ from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
|
||||
from core.constants import STATIC_DIR, _BufferHandler
|
||||
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader
|
||||
from routes import admin, settings, library, stt, sources, docker, tts, conversation, reader, characters, rehearsals_db
|
||||
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
@ -98,6 +98,8 @@ app.include_router(docker.router)
|
||||
app.include_router(tts.router)
|
||||
app.include_router(conversation.router)
|
||||
app.include_router(reader.router)
|
||||
app.include_router(characters.router)
|
||||
app.include_router(rehearsals_db.router)
|
||||
|
||||
# ── Static files ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.12.52">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -26,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.20">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.52">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -63,7 +64,7 @@
|
||||
opacity: 1;
|
||||
transition: opacity .25s ease;
|
||||
}
|
||||
#app-skeleton.sk-hidden { opacity: 0; pointer-events: none; }
|
||||
#app-skeleton.sk-hidden { opacity: 0; pointer-events: none; display: none; }
|
||||
.sk-head { display: flex; align-items: center; gap: 12px; }
|
||||
.sk-icon { width: 32px; height: 32px; border-radius: 8px; flex-shrink: 0; }
|
||||
.sk-title { display: flex; flex-direction: column; gap: 7px; }
|
||||
@ -156,7 +157,15 @@
|
||||
<div class="nav-tree" id="nav-speak-tree">
|
||||
<div class="nav-tree-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"><span class="mdi mdi-play"></span> Quick Play</div>
|
||||
<div class="nav-tree-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"><span class="mdi mdi-forum-outline"></span> Conversation</div>
|
||||
<div class="nav-tree-item" data-nav-section="s-reader" onclick="navTo('s-reader')"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</div>
|
||||
<div class="nav-tree-head nav-subhead" data-nav-section="s-reader" id="nav-reader-head" onclick="navTo('s-reader')">
|
||||
<span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span>
|
||||
<span class="nav-label">Read Aloud</span>
|
||||
<span class="nav-chevron" id="nav-reader-chevron" onclick="event.stopPropagation();toggleNavTree('nav-reader-tree','nav-reader-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
||||
</div>
|
||||
<div class="nav-tree" id="nav-reader-tree">
|
||||
<div class="nav-tree-item" onclick="navTo('s-reader');if(typeof showReaderView==='function')showReaderView('main')"><span class="mdi mdi-file-document-outline"></span> Reader</div>
|
||||
<div class="nav-tree-item" id="nav-reader-cast-item" onclick="navTo('s-reader');if(typeof audiobookOpenCastView==='function')audiobookOpenCastView();else if(typeof showReaderView==='function')showReaderView('cast')"><span class="mdi mdi-drama-masks"></span> Casting</div>
|
||||
</div>
|
||||
<div class="nav-tree-head nav-subhead" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
|
||||
<span class="nav-icon"><span class="mdi mdi-theater"></span></span>
|
||||
<span class="nav-label">Script Rehearsal</span>
|
||||
@ -335,8 +344,25 @@
|
||||
<script src="/static/vendor/wavesurfer.min.js"></script>
|
||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||
|
||||
<!-- Early stub: nav tree toggles are wired inline in the HTML and can fire
|
||||
before nav.js executes (slow / interrupted loads). This stub handles them
|
||||
by reading/writing localStorage directly so nav.js picks up the state. -->
|
||||
<script>
|
||||
window.toggleNavTree = function(treeId, chevronId) {
|
||||
try {
|
||||
var state = JSON.parse(localStorage.getItem('ttsvc_trees') || '{}');
|
||||
state[treeId] = !state[treeId];
|
||||
localStorage.setItem('ttsvc_trees', JSON.stringify(state));
|
||||
var tree = document.getElementById(treeId);
|
||||
var chev = document.getElementById(chevronId);
|
||||
if (tree) tree.classList.toggle('open', !!state[treeId]);
|
||||
if (chev) chev.classList.toggle('open', !!state[treeId]);
|
||||
} catch (_) {}
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.12.20"></script>
|
||||
<script src="/static/loader.js?v=1.12.52"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -651,7 +651,7 @@ window.initCollapsibleCards = function initCollapsibleCards() {
|
||||
|
||||
// Prepend a rotating chevron inside the h2
|
||||
const chev = document.createElement('span');
|
||||
chev.className = 'card-chev';
|
||||
chev.className = 'card-chev mdi mdi-chevron-down';
|
||||
chev.setAttribute('aria-hidden', 'true');
|
||||
h2.prepend(chev);
|
||||
h2.classList.add('card-collapse-h2');
|
||||
|
||||
@ -14,12 +14,36 @@
|
||||
const AUDIOBOOK_CHUNK_CHARS = 3000; // passage size per LLM attribution call
|
||||
const _audiobook = { running: false, cancel: false };
|
||||
|
||||
// Same hue algorithm as library.js _charHue so avatar colours match across views
|
||||
function _abCharHue(name) {
|
||||
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
|
||||
}
|
||||
// String coercer (mirrors library.js _libStr)
|
||||
function _abStr(v) {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||||
return String(v);
|
||||
}
|
||||
|
||||
// ── Autosave / draft recovery ─────────────────────────────────────────────────
|
||||
// Saves the accumulated segments to localStorage after every chunk so a page
|
||||
// refresh or browser crash doesn't lose hours of casting work.
|
||||
|
||||
const _AB_DRAFT_KEY = 'ttsvc_ab_draft';
|
||||
|
||||
// The book this casting session belongs to. When a saved library book is open,
|
||||
// the draft is keyed by its id so reopening the SAME book always restores its
|
||||
// cast — independent of any drift in the extracted text fingerprint (the old
|
||||
// text-only key silently lost the cast whenever PDF re-extraction differed even
|
||||
// slightly). Falls back to the global key for unsaved/ad-hoc documents.
|
||||
function _abBookId() {
|
||||
return (window.readerState && readerState.savedId) || _audiobook.bookId || null;
|
||||
}
|
||||
function _abDraftKey(bookId) {
|
||||
return bookId ? (_AB_DRAFT_KEY + '_' + bookId) : _AB_DRAFT_KEY;
|
||||
}
|
||||
|
||||
function _abTextId(text) {
|
||||
// Cheap fingerprint — no need for a full hash; length + first/last chars is enough
|
||||
// to detect doc changes without storing the source text itself.
|
||||
@ -30,33 +54,68 @@ function _abTextId(text) {
|
||||
}
|
||||
|
||||
function _abSaveDraft(segs, roster, text, done, total) {
|
||||
const bookId = _abBookId();
|
||||
const payload = {
|
||||
bookId: bookId,
|
||||
title: window.readerState?.title || '',
|
||||
textId: _abTextId(text),
|
||||
segments: segs,
|
||||
roster: roster,
|
||||
pageMarks: _audiobook.pageMarks || [],
|
||||
rehId: _audiobook.rehId || null,
|
||||
done: done,
|
||||
total: total,
|
||||
savedAt: Date.now()
|
||||
};
|
||||
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(payload)); } catch (_) {}
|
||||
// Mirror to server when a library book is open (fire-and-forget)
|
||||
if (bookId) {
|
||||
fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
async function _abLoadDraftServer(bookId) {
|
||||
if (!bookId) return null;
|
||||
try {
|
||||
localStorage.setItem(_AB_DRAFT_KEY, JSON.stringify({
|
||||
textId: _abTextId(text),
|
||||
segments: segs,
|
||||
roster: roster,
|
||||
pageMarks: _audiobook.pageMarks || [],
|
||||
rehId: _audiobook.rehId || null,
|
||||
done: done,
|
||||
total: total,
|
||||
savedAt: Date.now()
|
||||
}));
|
||||
} catch (_) {} // storage full / private mode — silently skip
|
||||
const r = await fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`);
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json();
|
||||
if (d && Array.isArray(d.segments) && d.segments.length) return d;
|
||||
} catch (_) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function _abLoadDraft(text) {
|
||||
const bookId = _abBookId();
|
||||
try {
|
||||
// 1. Per-book localStorage — fastest, no network
|
||||
if (bookId) {
|
||||
const raw = localStorage.getItem(_abDraftKey(bookId));
|
||||
if (raw) {
|
||||
const d = JSON.parse(raw);
|
||||
if (d && Array.isArray(d.segments) && d.segments.length) return d;
|
||||
}
|
||||
}
|
||||
// 2. Global draft (ad-hoc docs) — must match the text fingerprint
|
||||
const raw = localStorage.getItem(_AB_DRAFT_KEY);
|
||||
if (!raw) return null;
|
||||
const d = JSON.parse(raw);
|
||||
if (!d || !Array.isArray(d.segments) || !d.segments.length) return null;
|
||||
if (d.textId !== _abTextId(text)) return null; // different document
|
||||
if (d.textId !== _abTextId(text)) return null;
|
||||
return d;
|
||||
} catch (_) { return null; }
|
||||
}
|
||||
|
||||
function _abClearDraft() {
|
||||
try { localStorage.removeItem(_AB_DRAFT_KEY); } catch (_) {}
|
||||
const bookId = _abBookId();
|
||||
try { localStorage.removeItem(_abDraftKey(bookId)); } catch (_) {}
|
||||
if (bookId) {
|
||||
fetch(`/api/reader/docs/${encodeURIComponent(bookId)}/scripts/cast`, { method: 'DELETE' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// Opening/closing quote glyphs across book conventions: English "..."/“...”,
|
||||
@ -245,9 +304,9 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
}
|
||||
|
||||
panel.className = 'ab-castpanel-inline card';
|
||||
panel.style.display = 'flex';
|
||||
panel.style.flexDirection = 'column';
|
||||
panel.style.minHeight = '500px';
|
||||
panel.style.display = '';
|
||||
panel.style.flexDirection = '';
|
||||
panel.style.minHeight = '';
|
||||
|
||||
panel.innerHTML = `
|
||||
<div class="ab-castpanel-head" id="ab-cv-head">
|
||||
@ -280,18 +339,33 @@ function audiobookCastView(total, llmUrl, defaultModel, isIdle = false) {
|
||||
<button class="btn-primary btn-sm" id="ab-cv-prompt-save" title="Save current prompt as a preset"><span class="mdi mdi-content-save"></span> Save preset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reader-synth-track ab-castpanel-bar" style="position:relative; height:18px;">
|
||||
<div class="reader-synth-track ab-castpanel-bar" style="position:relative; height:18px;" hidden>
|
||||
<div class="reader-synth-fill" id="ab-cv-fill" style="height:100%;"></div>
|
||||
<div id="ab-cv-fill-text" style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; color:var(--text); text-shadow: 0px 1px 2px var(--bg), 0px -1px 2px var(--bg); pointer-events:none;">0%</div>
|
||||
<div id="ab-cv-fill-text" style="position:absolute; inset:0; display:flex; align-items:center; justify-content:center; font-size:11px; font-weight:600; color:var(--text); text-shadow: 0px 1px 2px var(--bg), 0px -1px 2px var(--bg); pointer-events:none;"></div>
|
||||
</div>
|
||||
<div class="ab-cv-body">
|
||||
<div class="ab-cv-feed-wrap">
|
||||
<div class="ab-cv-feed" id="ab-cv-feed"></div>
|
||||
<div class="ab-cv-feed" id="ab-cv-feed">
|
||||
<div class="ab-skel-feed">
|
||||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:78%"></div><div class="ab-skel-line" style="width:55%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:68%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:91%"></div><div class="ab-skel-line" style="width:42%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:73%"></div><div class="ab-skel-line" style="width:30%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:60%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:82%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-narr"><div class="ab-skel-line" style="width:88%"></div><div class="ab-skel-line" style="width:48%"></div></div>
|
||||
<div class="ab-skel-row ab-skel-dlg"><div class="ab-skel-spk"></div><div class="ab-skel-line" style="width:64%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<button class="ab-cv-jump-btn" id="ab-cv-jump-btn" hidden title="Jump to latest"><span class="mdi mdi-chevron-double-down"></span> Live</button>
|
||||
</div>
|
||||
<div class="ab-cv-side">
|
||||
<div class="ab-cv-side-head">Characters found</div>
|
||||
<div class="ab-cv-chars" id="ab-cv-chars"><span class="ab-cv-empty">reading…</span></div>
|
||||
<div class="ab-cv-chars" id="ab-cv-chars">
|
||||
<div class="ab-skel-chars">
|
||||
${[38,62,45,28,54,35].map(w => `<div class="ab-skel-char-row"><div class="ab-skel-dot"></div><div class="ab-skel-line" style="width:${w}%"></div><div class="ab-skel-num"></div></div>`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -542,17 +616,288 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
}
|
||||
return html;
|
||||
};
|
||||
// ── Character selection bar ────────────────────────────────────────────────
|
||||
// Clicking a character shows a compact bar above the feed with avatar, name,
|
||||
// prev/next line navigation, a search box, and a "Profil" button that opens
|
||||
// the full character sheet (hiding the feed temporarily).
|
||||
|
||||
const feedWrap = panel.querySelector('.ab-cv-feed-wrap');
|
||||
const jumpBtn = panel.querySelector('#ab-cv-jump-btn');
|
||||
|
||||
// Insert bar above the feed (once)
|
||||
const _abBar = document.createElement('div');
|
||||
_abBar.className = 'ab-char-bar';
|
||||
_abBar.hidden = true;
|
||||
feedWrap.insertBefore(_abBar, feedWrap.firstChild);
|
||||
|
||||
let _abCharRows = []; // rows in feed for selected character
|
||||
let _abNavIdx = 0;
|
||||
let _abDetailEl = null; // full profile panel (when Profil is open)
|
||||
|
||||
const _abClearHL = () => {
|
||||
feed.querySelectorAll('.ab-cv-row.ab-char-hl, .ab-cv-row.ab-char-focus')
|
||||
.forEach(r => r.classList.remove('ab-char-hl', 'ab-char-focus'));
|
||||
};
|
||||
|
||||
const _abCloseProfile = () => {
|
||||
if (_abDetailEl) { _abDetailEl.remove(); _abDetailEl = null; }
|
||||
feed.style.display = '';
|
||||
if (jumpBtn && typeof _userScrolled !== 'undefined') jumpBtn.hidden = !_userScrolled;
|
||||
_abCharRows.forEach(r => r.classList.add('ab-char-hl'));
|
||||
const btn = _abBar.querySelector('.ab-char-bar-profile');
|
||||
if (btn) btn.innerHTML = '<span class="mdi mdi-account-details-outline"></span> Profil';
|
||||
};
|
||||
|
||||
const _abCloseBar = () => {
|
||||
_abBar.hidden = true;
|
||||
_abBar.innerHTML = '';
|
||||
_abCloseProfile();
|
||||
_abClearHL();
|
||||
_abCharRows = [];
|
||||
chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.remove('is-active'));
|
||||
};
|
||||
|
||||
const _abNav = (dir, pool) => {
|
||||
const rows = pool || _abCharRows;
|
||||
if (!rows.length) return;
|
||||
_abNavIdx = ((_abNavIdx + dir) + rows.length) % rows.length;
|
||||
feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus'));
|
||||
rows[_abNavIdx].classList.add('ab-char-focus');
|
||||
rows[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
const pos = _abBar.querySelector('.ab-char-bar-pos');
|
||||
if (pos) pos.textContent = (_abNavIdx + 1) + ' / ' + rows.length;
|
||||
};
|
||||
|
||||
// Open the full character profile (hides feed)
|
||||
const _abShowProfile = async (name) => {
|
||||
_abCloseProfile();
|
||||
feed.style.display = 'none';
|
||||
if (jumpBtn) jumpBtn.hidden = true;
|
||||
const profileBtn = _abBar.querySelector('.ab-char-bar-profile');
|
||||
if (profileBtn) profileBtn.innerHTML = '<span class="mdi mdi-arrow-left"></span> Skript';
|
||||
|
||||
const title = window.readerState?.title || '';
|
||||
let rec = null;
|
||||
try {
|
||||
const all = typeof clGetAllByTagOrBook === 'function'
|
||||
? await clGetAllByTagOrBook(title)
|
||||
: (typeof clGetAll === 'function' ? await clGetAll() : []);
|
||||
rec = all.find(r => (r.name || '').toLowerCase() === name.toLowerCase());
|
||||
} catch (_) {}
|
||||
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'ab-char-detail-panel';
|
||||
_abDetailEl = detail;
|
||||
|
||||
if (!rec) {
|
||||
detail.innerHTML = '<button class="ab-char-detail-back"><span class="mdi mdi-arrow-left"></span> Zurück zum Skript</button>'
|
||||
+ '<div style="padding:24px;opacity:.6;font-size:14px">Kein Charakterblatt – zuerst „Cast Characters" ausführen.</div>';
|
||||
feedWrap.appendChild(detail);
|
||||
detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile);
|
||||
return;
|
||||
}
|
||||
|
||||
const sh = rec.sheet || {};
|
||||
const hue = _abCharHue(rec.name), hue2 = (hue + 40) % 360;
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : 'Nebenfigur';
|
||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||||
const pct = rec.sheet?.moral_alignment_score != null ? Math.max(0, Math.min(100, rec.sheet.moral_alignment_score)) : null;
|
||||
const arcMap = { 'good-to-bad':'↘ Entwicklung zum Bösen','bad-to-good':'↗ Wandel zum Guten','complex':'↕ Komplex','stable-good':'→ Stabil gut','stable-bad':'→ Stabil böse','neutral':'→ Neutral' };
|
||||
const avatarHtml = rec.image
|
||||
? `<div class="lcd-avatar"><img src="${rec.image}" alt="${escHtml(rec.name)}"></div>`
|
||||
: `<div class="lcd-avatar" style="background:hsl(${hue},55%,38%)">${escHtml((rec.name||'?')[0].toUpperCase())}</div>`;
|
||||
// field(label, value, sheet-key) — key enables editing when pencil is active
|
||||
const field = (lbl, val, sk) => {
|
||||
const v = _abStr(val);
|
||||
if (!v && !sk) return '';
|
||||
return `<div class="lcd-field"><div class="lcd-field-label">${lbl}</div><div class="lcd-field-value${sk ? ' ab-editable' : ''}"${sk ? ` data-sk="${sk}"` : ''}>${escHtml(v)}</div></div>`;
|
||||
};
|
||||
|
||||
// Sources section
|
||||
const srcItems = Array.isArray(rec.sources) ? rec.sources : [];
|
||||
const sourcesHtml = srcItems.length
|
||||
? `<div class="lcd-section-full">
|
||||
<div class="lcd-section-label"><span class="mdi mdi-map-marker-outline"></span> Quellen im Text</div>
|
||||
<div class="lcd-sources">${srcItems.map(s => `<span class="lcd-source-clickable ab-cd-src" data-page="${s.page||0}">Seite ${s.page||'?'}</span>`).join('')}</div>
|
||||
</div>` : '';
|
||||
|
||||
detail.innerHTML =
|
||||
`<button class="ab-char-detail-back"><span class="mdi mdi-arrow-left"></span> Zurück zum Skript</button>
|
||||
<div class="lcd-header" style="--lc1:hsl(${hue},55%,35%);--lc2:hsl(${hue2},58%,25%); position:relative;">
|
||||
${avatarHtml}
|
||||
<div class="lcd-header-body">
|
||||
<div class="lcd-name">${escHtml(rec.name)}</div>
|
||||
${_abStr(sh.aliases) ? `<div class="lcd-aliases">auch bekannt als ${escHtml(_abStr(sh.aliases))}</div>` : ''}
|
||||
${_abStr(sh.archetype) ? `<div class="lcd-archetype">${escHtml(_abStr(sh.archetype))}</div>` : ''}
|
||||
<div class="lcd-tier-gender"><span class="lcd-tier">${tierLabel}</span></div>
|
||||
</div>
|
||||
<button class="ab-cd-edit-btn" title="Charakterblatt bearbeiten"><span class="mdi mdi-pencil-outline"></span></button>
|
||||
</div>
|
||||
<div class="lcd-body ab-cd-body">
|
||||
<div class="lcd-voice-top">
|
||||
<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>
|
||||
<span class="lcd-voice-label">${voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme</span>'}</span>
|
||||
<button class="ab-cd-pick">Auswählen</button><button class="ab-cd-auto">Automatisch</button><button class="ab-cd-online">Online suchen</button>
|
||||
</div>
|
||||
${pct != null ? `<div class="lcd-align-section"><div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div><div class="lcd-align-bar-wrap"><span class="lcd-align-label">Böse</span><div class="lcd-align-bar"><div class="lcd-align-dot" style="left:${pct}%"></div></div><span class="lcd-align-label">Gut</span></div><div class="lcd-align-arc">${arcMap[sh.arc_direction||'neutral']||'→'} · ${pct>=70?'Rechtschaffen':pct<=30?'Böse':'Ambivalent'} (${pct}/100)</div></div>` : ''}
|
||||
<div class="lcd-sections">
|
||||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-account-outline"></span> Erscheinung</div>${field('Körperlich',sh.physical,'physical')}${field('Kleidung',sh.clothing,'clothing')}</div>
|
||||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-drama-masks"></span> Persönlichkeit</div>${field('Eigenheiten',sh.mannerisms,'mannerisms')}${field('Stimme & Sprache',sh.voice_pattern,'voice_pattern')}</div>
|
||||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-book-open-outline"></span> Geschichte</div>${field('Hintergrund',sh.backstory,'backstory')}${field('Motivation',sh.motivation,'motivation')}</div>
|
||||
<div class="lcd-section"><div class="lcd-section-label"><span class="mdi mdi-sword"></span> Fähigkeiten</div>${field('Fertigkeiten',sh.skills,'skills')}${field('Besondere Fähigkeiten',sh.capabilities,'capabilities')}</div>
|
||||
<div class="lcd-section-full"><div class="lcd-section-label"><span class="mdi mdi-account-group-outline"></span> Beziehungen</div>${field('',sh.relationships,'relationships')}</div>
|
||||
${sourcesHtml}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
feedWrap.appendChild(detail);
|
||||
detail.querySelector('.ab-char-detail-back').addEventListener('click', _abCloseProfile);
|
||||
detail.querySelector('.ab-cd-pick')?.addEventListener('click', () => {
|
||||
if (typeof _openVoicePicker === 'function')
|
||||
_openVoicePicker(detail.querySelector('.lcd-voice-top'), rec, async () => {
|
||||
const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id);
|
||||
if (up) { _abCloseProfile(); _abShowProfile(up.name); }
|
||||
});
|
||||
});
|
||||
detail.querySelector('.ab-cd-auto')?.addEventListener('click', async () => {
|
||||
if (typeof _autoAssignVoice === 'function') {
|
||||
await _autoAssignVoice(rec);
|
||||
const up = (typeof clGetAll === 'function' ? await clGetAll() : []).find(r => r.id === rec.id);
|
||||
if (up) { _abCloseProfile(); _abShowProfile(up.name); }
|
||||
}
|
||||
});
|
||||
detail.querySelector('.ab-cd-online')?.addEventListener('click', () => { if (typeof _charSearchOnline === 'function') _charSearchOnline(rec); });
|
||||
|
||||
// Pencil edit toggle
|
||||
let _saveTimer = null;
|
||||
const editBtn = detail.querySelector('.ab-cd-edit-btn');
|
||||
editBtn?.addEventListener('click', function () {
|
||||
const editing = detail.classList.toggle('ab-cd-editing');
|
||||
this.innerHTML = editing ? '<span class="mdi mdi-check"></span>' : '<span class="mdi mdi-pencil-outline"></span>';
|
||||
this.title = editing ? 'Fertig' : 'Charakterblatt bearbeiten';
|
||||
detail.querySelectorAll('.ab-editable').forEach(el => {
|
||||
el.contentEditable = editing ? 'true' : 'false';
|
||||
if (editing) {
|
||||
el.addEventListener('input', function onInput() {
|
||||
clearTimeout(_saveTimer);
|
||||
_saveTimer = setTimeout(async () => {
|
||||
if (!rec.sheet) rec.sheet = {};
|
||||
rec.sheet[el.dataset.sk] = el.textContent.trim();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
}, 900);
|
||||
});
|
||||
}
|
||||
});
|
||||
if (editing) detail.querySelector('.ab-editable')?.focus();
|
||||
});
|
||||
|
||||
// Source page clicks → jump to reader
|
||||
detail.querySelectorAll('.ab-cd-src').forEach(el => {
|
||||
el.addEventListener('click', () => {
|
||||
const pg = parseInt(el.dataset.page, 10);
|
||||
if (!pg) return;
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
setTimeout(() => {
|
||||
const pages = window.readerState?.pages;
|
||||
if (pages && pages.length >= pg && pages[pg-1]?.pageDiv) {
|
||||
pages[pg-1].pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else if (typeof toast === 'function') {
|
||||
toast('Buch im „Vorlesen"-Bereich öffnen, dann nochmal klicken', 'info');
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Select a character: show bar + highlight rows, keep feed visible
|
||||
const _abSelectChar = (name) => {
|
||||
// Toggle off if same character clicked again
|
||||
if (!_abBar.hidden && _abBar.dataset.charName === name) { _abCloseBar(); return; }
|
||||
_abCloseBar();
|
||||
chars.querySelectorAll('.ab-char-item').forEach(el => el.classList.toggle('is-active', el.dataset.name === name));
|
||||
|
||||
_abCharRows = Array.from(feed.querySelectorAll('.ab-cv-row')).filter(r =>
|
||||
r.__seg?.speaker && r.__seg.speaker.toLowerCase() === name.toLowerCase()
|
||||
);
|
||||
_abNavIdx = 0;
|
||||
|
||||
const hue = _abCharHue(name);
|
||||
_abBar.dataset.charName = name;
|
||||
_abBar.hidden = false;
|
||||
_abBar.innerHTML =
|
||||
`<span class="ab-char-bar-dot" style="background:hsl(${hue},55%,38%)">${(name||'?')[0].toUpperCase()}</span>
|
||||
<div class="ab-char-bar-info">
|
||||
<span class="ab-char-bar-name">${escHtml(name)}</span>
|
||||
<span class="ab-char-bar-cnt">${_abCharRows.length} Zeilen</span>
|
||||
</div>
|
||||
<div class="ab-char-bar-nav">
|
||||
<button class="ab-char-bar-prev" title="Vorherige Zeile"><span class="mdi mdi-chevron-up"></span></button>
|
||||
<span class="ab-char-bar-pos">1 / ${_abCharRows.length}</span>
|
||||
<button class="ab-char-bar-next" title="Nächste Zeile"><span class="mdi mdi-chevron-down"></span></button>
|
||||
</div>
|
||||
<input class="ab-char-bar-search" type="search" placeholder="In Zeilen suchen…" autocomplete="off">
|
||||
<button class="ab-char-bar-profile" title="Charakterblatt öffnen"><span class="mdi mdi-account-details-outline"></span> Profil</button>
|
||||
<button class="ab-char-bar-close" title="Auswahl aufheben"><span class="mdi mdi-close"></span></button>`;
|
||||
|
||||
// highlight all + jump to first
|
||||
_abCharRows.forEach(r => r.classList.add('ab-char-hl'));
|
||||
if (_abCharRows.length) {
|
||||
_abCharRows[0].classList.add('ab-char-focus');
|
||||
_abCharRows[0].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
|
||||
_abBar.querySelector('.ab-char-bar-prev').addEventListener('click', () => _abNav(-1));
|
||||
_abBar.querySelector('.ab-char-bar-next').addEventListener('click', () => _abNav(1));
|
||||
_abBar.querySelector('.ab-char-bar-profile').addEventListener('click', () => {
|
||||
if (_abDetailEl) _abCloseProfile(); else _abShowProfile(name);
|
||||
});
|
||||
_abBar.querySelector('.ab-char-bar-close').addEventListener('click', _abCloseBar);
|
||||
|
||||
// Search within this character's rows
|
||||
const inp = _abBar.querySelector('.ab-char-bar-search');
|
||||
let _st = null;
|
||||
const _doSearch = (jump) => {
|
||||
const q = inp.value.trim().toLowerCase();
|
||||
const pool = q ? _abCharRows.filter(r =>
|
||||
(r.__seg?.text || r.querySelector('.ab-cv-txt')?.textContent || '').toLowerCase().includes(q)
|
||||
) : _abCharRows;
|
||||
feed.querySelectorAll('.ab-cv-row.ab-char-focus').forEach(r => r.classList.remove('ab-char-focus'));
|
||||
if (pool.length) {
|
||||
if (jump) _abNavIdx = (_abNavIdx + 1) % pool.length;
|
||||
else _abNavIdx = 0;
|
||||
pool[_abNavIdx].classList.add('ab-char-focus');
|
||||
pool[_abNavIdx].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
_abBar.querySelector('.ab-char-bar-pos').textContent = (_abNavIdx + 1) + ' / ' + pool.length;
|
||||
} else {
|
||||
_abBar.querySelector('.ab-char-bar-pos').textContent = '0 Treffer';
|
||||
}
|
||||
};
|
||||
inp.addEventListener('input', () => { clearTimeout(_st); _st = setTimeout(() => _doSearch(false), 200); });
|
||||
inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); _doSearch(true); } });
|
||||
};
|
||||
|
||||
const renderRoster = () => {
|
||||
const items = [...roster.entries()].filter(([, info]) => info.count > 0).sort((a, b) => b[1].count - a[1].count);
|
||||
chars.innerHTML = items.length
|
||||
? items.map(([n, info]) => `<span class="ab-chip" style="--c:${info.color}"><span class="ab-chip-dot"></span>${escHtml(n)}<b>${info.count}</b></span>`).join('')
|
||||
: '<span class="ab-cv-empty">reading…</span>';
|
||||
if (!items.length) { chars.innerHTML = '<span class="ab-cv-empty">reading…</span>'; return; }
|
||||
const prev = _abBar.hidden ? null : _abBar.dataset.charName;
|
||||
chars.innerHTML = items.map(([n]) => {
|
||||
const hue = _abCharHue(n);
|
||||
return `<div class="ab-char-item${prev===n?' is-active':''}" data-name="${escHtml(n)}">
|
||||
<span class="ab-char-dot" style="background:hsl(${hue},55%,38%)">${escHtml((n||'?')[0].toUpperCase())}</span>
|
||||
<span class="ab-char-name">${escHtml(n)}</span>
|
||||
<b class="ab-char-count">${roster.get(n)?.count||0}</b>
|
||||
</div>`;
|
||||
}).join('');
|
||||
chars.querySelectorAll('.ab-char-item').forEach(item => {
|
||||
item.addEventListener('click', () => _abSelectChar(item.dataset.name));
|
||||
});
|
||||
};
|
||||
const MAXROWS = 600; // keep most of the book visible; only trim when user is at bottom
|
||||
// Whether the user has scrolled up to review/edit earlier content.
|
||||
// While true: auto-scroll and trim are suppressed so their position is preserved.
|
||||
let _userScrolled = false;
|
||||
const jumpBtn = panel.querySelector('#ab-cv-jump-btn');
|
||||
// jumpBtn already declared above (in the character-detail block)
|
||||
feed.addEventListener('scroll', () => {
|
||||
const atBottom = feed.scrollTop + feed.clientHeight >= feed.scrollHeight - 80;
|
||||
_userScrolled = !atBottom;
|
||||
@ -879,10 +1224,12 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
});
|
||||
|
||||
return {
|
||||
update(done) {
|
||||
update(done) {
|
||||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||||
if (castBar) castBar.hidden = false;
|
||||
const pct = Math.round((done / total) * 100);
|
||||
if (fill) fill.style.width = pct + '%';
|
||||
if (count) count.textContent = `passage ${done} / ${total}`;
|
||||
if (fill) fill.style.width = pct + '%';
|
||||
if (count) { count.hidden = false; count.textContent = `passage ${done} / ${total}`; }
|
||||
const fillText = panel.querySelector('#ab-cv-fill-text');
|
||||
if (fillText) fillText.textContent = `${pct}% (Passage ${done} of ${total})`;
|
||||
},
|
||||
@ -916,8 +1263,10 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
},
|
||||
addSegments(segs) {
|
||||
this.clearProcessing();
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of segs) {
|
||||
feed.querySelector('.ab-skel-feed')?.remove();
|
||||
chars.querySelector('.ab-skel-chars')?.remove();
|
||||
|
||||
const makeRow = (s) => {
|
||||
const isNarrator = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||||
const speakerName = isNarrator ? 'Narrator' : s.speaker;
|
||||
const c = colorFor(speakerName);
|
||||
@ -926,9 +1275,33 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
row.className = 'ab-cv-row' + (isNarrator ? ' is-narr' : '');
|
||||
row.__seg = s;
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(speakerName)}${s.emotion ? ' <span style="text-transform:lowercase; font-weight:normal; opacity:0.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
frag.appendChild(row);
|
||||
return row;
|
||||
};
|
||||
|
||||
const CHUNK = 80;
|
||||
// Render first chunk immediately so the screen fills fast
|
||||
const first = segs.slice(0, CHUNK);
|
||||
const frag0 = document.createDocumentFragment();
|
||||
first.forEach(s => frag0.appendChild(makeRow(s)));
|
||||
feed.appendChild(frag0);
|
||||
renderRoster();
|
||||
|
||||
// Stream remaining chunks without blocking the main thread
|
||||
if (segs.length > CHUNK) {
|
||||
let i = CHUNK;
|
||||
const next = () => {
|
||||
if (i >= segs.length) { trim(); renderRoster(); return; }
|
||||
const batch = document.createDocumentFragment();
|
||||
const end = Math.min(i + CHUNK, segs.length);
|
||||
for (; i < end; i++) batch.appendChild(makeRow(segs[i]));
|
||||
feed.appendChild(batch);
|
||||
renderRoster();
|
||||
setTimeout(next, 0);
|
||||
};
|
||||
setTimeout(next, 0);
|
||||
} else {
|
||||
trim();
|
||||
}
|
||||
feed.appendChild(frag); trim(); renderRoster();
|
||||
},
|
||||
note(text) { const r = document.createElement('div'); r.className = 'ab-cv-note'; r.textContent = text; feed.appendChild(r); trim(); },
|
||||
// Visual gap marking that the passages before/after are not contiguous.
|
||||
@ -947,17 +1320,43 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
r.addEventListener('click', () => {
|
||||
const gap = segsAll.slice(gapFrom, gapTo + 1);
|
||||
if (!gap.length) { r.remove(); return; }
|
||||
const PEEK = 20;
|
||||
const show = gap.slice(0, PEEK);
|
||||
const rest = gap.slice(PEEK);
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const s of gap) {
|
||||
for (const s of show) {
|
||||
const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||||
const spk = isNarr ? 'Narrator' : s.speaker;
|
||||
const c = colorFor(spk);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
||||
row.__seg = s;
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(${escHtml(s.emotion)})</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
frag.appendChild(row);
|
||||
}
|
||||
if (rest.length) {
|
||||
// Insert a new collapsed divider for the remaining lines
|
||||
const next = document.createElement('div');
|
||||
next.className = 'ab-cv-divider ab-cv-divider-expand';
|
||||
next.innerHTML = `⋯ <span class="ab-cv-divider-hint">${rest.length} line${rest.length !== 1 ? 's' : ''} hidden — click to expand</span>`;
|
||||
next.title = 'Click to reveal more';
|
||||
const restSegs = rest; // closure
|
||||
next.addEventListener('click', function onClick() {
|
||||
const f2 = document.createDocumentFragment();
|
||||
for (const s of restSegs) {
|
||||
const isNarr = s.type !== 'dialogue' || !s.speaker || s.speaker.toLowerCase() === 'narrator';
|
||||
const spk = isNarr ? 'Narrator' : s.speaker;
|
||||
const c = colorFor(spk);
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ab-cv-row ab-cv-ctx-row' + (isNarr ? ' is-narr' : '');
|
||||
row.__seg = s;
|
||||
row.innerHTML = `<span class="ab-cv-spk" style="color:${c}" title="Click to assign character">${escHtml(spk)}${s.emotion ? ' <span style="text-transform:lowercase;font-weight:normal;opacity:.8">(' + escHtml(s.emotion) + ')</span>' : ''}</span><span class="ab-cv-txt">${highlightText(s.text || '')}</span>`;
|
||||
f2.appendChild(row);
|
||||
}
|
||||
next.replaceWith(f2);
|
||||
});
|
||||
frag.appendChild(next);
|
||||
}
|
||||
r.replaceWith(frag);
|
||||
});
|
||||
feed.appendChild(r); trim();
|
||||
@ -972,8 +1371,9 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
// Park the panel in a "done" state with a Review button instead of auto-popping
|
||||
// the preview — so it waits for you if you wandered off to do something else.
|
||||
complete(summary, onOpen, onRecast, onRecastUnknown) {
|
||||
if (count) count.textContent = 'done';
|
||||
if (fill) fill.style.width = '100%';
|
||||
if (count) count.hidden = true;
|
||||
const castBar = panel.querySelector('.ab-castpanel-bar');
|
||||
if (castBar) castBar.hidden = true;
|
||||
if (typeof allSegments !== 'undefined' && allSegments.length) {
|
||||
feed.innerHTML = '';
|
||||
const frag = document.createDocumentFragment();
|
||||
@ -989,13 +1389,11 @@ STRIKTE FORMAT- UND TEXTREGELN:
|
||||
}
|
||||
feed.appendChild(frag);
|
||||
}
|
||||
const fillText = panel.querySelector('#ab-cv-fill-text');
|
||||
if (fillText) fillText.textContent = '100% - Ready for review';
|
||||
const cancelBtn = panel.querySelector('#ab-cv-cancel');
|
||||
if (cancelBtn) cancelBtn.hidden = true;
|
||||
const foot = panel.querySelector('#ab-cv-foot');
|
||||
foot.hidden = false;
|
||||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality increase run — re-checks every speaker assignment against context and resolves all Unknowns"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Open in Script Rehearser to assign voices and synthesise"><span class="mdi mdi-drama-masks"></span> Edit in Rehearser</button>`;
|
||||
foot.innerHTML = `<span class="ab-cv-done"><span class="mdi mdi-check-circle-outline"></span> ${escHtml(summary)}</span><span style="flex:1"></span><button class="btn-secondary btn-sm" id="ab-cv-recast-unk" style="margin-right:8px; color:var(--error);" title="Re-run only the Unknown segments with the current prompt"><span class="mdi mdi-account-question-outline"></span> Recast unknown</button><button class="btn-secondary btn-sm" id="ab-cv-recast" style="margin-right:8px;" title="Re-run the entire document to apply new settings or prompt tweaks"><span class="mdi mdi-refresh"></span> Recast all</button><button class="btn-secondary btn-sm" id="ab-cv-verify" style="margin-right:8px; border-color:var(--accent); color:var(--accent); font-weight:600;" title="2nd Quality increase run — re-checks every speaker assignment against context and resolves all Unknowns"><span class="mdi mdi-shield-check-outline"></span> 2nd Quality Run</button><button class="btn-secondary btn-sm" id="ab-cv-cast-chars" style="margin-right:8px;" title="Generate character sheets and match voices to each found character"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button><button class="btn-primary btn-sm" id="ab-cv-open-reh" title="Open in Script Rehearser to assign voices and synthesise"><span class="mdi mdi-drama-masks"></span> Edit in Rehearser</button>`;
|
||||
|
||||
const runVerificationPass = () => {
|
||||
const verificationPrompt = `Du bist ein Qualitätsprüfer für die Analyse eines deutschen Hörbuchs. Eine erste KI hat den Textauszug bereits in Segmente unterteilt und jedem Segment einen Sprecher zugewiesen. Deine Aufgabe ist es JEDEN Sprecher-Zuweisung kritisch zu überprüfen und zu korrigieren.
|
||||
@ -1057,10 +1455,33 @@ ABSOLUTE REGELN:
|
||||
|
||||
foot.querySelector('#ab-cv-recast').addEventListener('click', () => applyPromptAndRun(audiobookCast));
|
||||
foot.querySelector('#ab-cv-recast-unk').addEventListener('click', () => applyPromptAndRun(audiobookRecastUnknown));
|
||||
foot.querySelector('#ab-cv-cast-chars').addEventListener('click', () => {
|
||||
if (typeof window.csForReader === 'function') window.csForReader();
|
||||
else if (typeof csForReader === 'function') csForReader();
|
||||
else toast('Character sheets not loaded yet', 'error');
|
||||
});
|
||||
panel.classList.add('ab-castpanel-done');
|
||||
if (typeof window.setNavCastingBadge === 'function') window.setNavCastingBadge(false);
|
||||
},
|
||||
done() { closePanel(); },
|
||||
// Show fresh "Ready to cast" state (used when server draft fetch returns empty)
|
||||
setFreshState() {
|
||||
feed.querySelector('.ab-skel-feed')?.remove();
|
||||
chars.querySelector('.ab-skel-chars')?.remove();
|
||||
const statusMsg = panel.querySelector('#ab-cv-status-msg');
|
||||
if (statusMsg) statusMsg.style.display = 'inline-block';
|
||||
const castBtn = panel.querySelector('#ab-cv-start-cast');
|
||||
if (castBtn) {
|
||||
castBtn.style.display = 'inline-block';
|
||||
castBtn.addEventListener('click', () => {
|
||||
const newPrompt = panel.querySelector('#ab-cv-prompt-text')?.value;
|
||||
const currentUrl = panel.querySelector('#ab-cv-llm-url')?.value.trim();
|
||||
const currentModel = panel.querySelector('#ab-cv-llm-select')?.value;
|
||||
if (typeof _appSettings !== 'undefined' && newPrompt) _appSettings.audiobook_prompt = newPrompt;
|
||||
audiobookCast(currentUrl, currentModel);
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -1180,6 +1601,15 @@ function audiobookOpenCastView() {
|
||||
const text = audiobookScopeText();
|
||||
if (!text) { toast('Import a document first', 'error'); return; }
|
||||
|
||||
// Bind this casting session to the open library book so its draft is restored
|
||||
// by identity on reopen. Clearing the in-memory cast when the book changes
|
||||
// prevents a stale in-memory cast from masking the correct per-book draft.
|
||||
const _curBook = (window.readerState && readerState.savedId) || null;
|
||||
if (_curBook && _audiobook.bookId && _audiobook.bookId !== _curBook) {
|
||||
_audiobook.segments = null; _audiobook.lastText = null; _audiobook.roster = null;
|
||||
}
|
||||
_audiobook.bookId = _curBook;
|
||||
|
||||
const llm_url = audiobookLlmUrl();
|
||||
const model = audiobookLlmModel();
|
||||
|
||||
@ -1198,33 +1628,60 @@ function audiobookOpenCastView() {
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. localStorage draft restore — survived a page refresh or browser crash
|
||||
const _draft = _abLoadDraft(text);
|
||||
if (_draft && _draft.segments && _draft.segments.length > 0) {
|
||||
_audiobook.segments = _draft.segments;
|
||||
_audiobook.roster = _draft.roster || [];
|
||||
// Helper: apply a draft object into the view
|
||||
const _applyDraft = (draft, view, source) => {
|
||||
_audiobook.segments = draft.segments;
|
||||
_audiobook.roster = draft.roster || [];
|
||||
_audiobook.lastText = text;
|
||||
_audiobook.pageMarks = _draft.pageMarks || [];
|
||||
_audiobook.rehId = _draft.rehId || null;
|
||||
|
||||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||||
view.addSegments(_draft.segments);
|
||||
|
||||
const ageMs = Date.now() - (_draft.savedAt || 0);
|
||||
_audiobook.pageMarks = draft.pageMarks || [];
|
||||
_audiobook.rehId = draft.rehId || null;
|
||||
view.addSegments(draft.segments);
|
||||
const ageMs = Date.now() - (draft.savedAt || 0);
|
||||
const ageMins = Math.round(ageMs / 60000);
|
||||
const ageStr = ageMins < 1 ? 'just now' : ageMins < 60 ? `${ageMins}m ago` : `${Math.round(ageMins / 60)}h ago`;
|
||||
const pct = _draft.total > 0 ? Math.round((_draft.done / _draft.total) * 100) : 100;
|
||||
const wasDone = _draft.done === _draft.total || _draft.done < 0;
|
||||
view.note(`🔄 Session restored from autosave — ${pct}% complete, saved ${ageStr}.${!wasDone ? ' Casting was interrupted; click "Recast all" to fill in the rest, or "Review & cast" to work with what\'s here.' : ''}`);
|
||||
|
||||
const speakers = new Set(_draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const summary = `${speakers.size} character${speakers.size !== 1 ? 's' : ''} · ${_draft.segments.length} segments`;
|
||||
const ageStr = ageMins < 1 ? 'gerade eben' : ageMins < 60 ? `vor ${ageMins} Min.` : `vor ${Math.round(ageMins/60)} Std.`;
|
||||
const pct = draft.total > 0 ? Math.round((draft.done / draft.total) * 100) : 100;
|
||||
const wasDone = draft.done === draft.total || draft.done < 0;
|
||||
const src = source === 'server' ? '☁️ Vom Server geladen' : '🔄 Autosave wiederhergestellt';
|
||||
view.note(`${src} — ${pct}% vollständig, gespeichert ${ageStr}.${!wasDone ? ' Casting wurde unterbrochen — „Recast all" für komplette Neuauswertung.' : ''}`);
|
||||
const speakers = new Set(draft.segments.filter(s => s.type === 'dialogue' && s.speaker).map(s => s.speaker));
|
||||
const summary = `${speakers.size} Charakter${speakers.size !== 1 ? 'e' : ''} · ${draft.segments.length} Segmente`;
|
||||
view.complete(summary, audiobookShowPreview, audiobookCast, audiobookRecastUnknown);
|
||||
view.update(_draft.done > 0 ? _draft.done : chunks.length);
|
||||
view.update(draft.done > 0 ? draft.done : chunks.length);
|
||||
};
|
||||
|
||||
// 2. localStorage draft restore — fastest, no network
|
||||
const _localDraft = _abLoadDraft(text);
|
||||
if (_localDraft && _localDraft.segments && _localDraft.segments.length > 0) {
|
||||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||||
_applyDraft(_localDraft, view, 'local');
|
||||
// Backfill server in case it's missing this draft
|
||||
if (_abBookId()) {
|
||||
fetch(`/api/reader/docs/${encodeURIComponent(_abBookId())}/scripts/cast`, {
|
||||
method: 'PUT', headers: {'Content-Type':'application/json'},
|
||||
body: JSON.stringify(_localDraft)
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Fresh start
|
||||
// 3. Server draft restore — for other browsers/devices or cleared localStorage
|
||||
const bookId = _abBookId();
|
||||
if (bookId) {
|
||||
const view = audiobookCastView(chunks.length, llm_url, model, false);
|
||||
_abLoadDraftServer(bookId).then(serverDraft => {
|
||||
if (serverDraft && serverDraft.segments && serverDraft.segments.length > 0) {
|
||||
// Cache locally so next open is instant
|
||||
try { localStorage.setItem(_abDraftKey(bookId), JSON.stringify(serverDraft)); } catch(_) {}
|
||||
_applyDraft(serverDraft, view, 'server');
|
||||
} else {
|
||||
// No draft anywhere — show fresh state
|
||||
view.setFreshState();
|
||||
}
|
||||
}).catch(() => view.setFreshState());
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. Fresh start (no book ID, no draft)
|
||||
audiobookCastView(chunks.length, llm_url, model, true);
|
||||
}
|
||||
|
||||
|
||||
@ -58,23 +58,35 @@ const CS_SCALAR_FIELDS = ['aliases', 'archetype', 'physical', 'clothing', 'align
|
||||
|
||||
function csExistingSummary(map) {
|
||||
if (!map.size) return '';
|
||||
return [...map.values()].slice(0, 30).map(s => {
|
||||
const missing = CS_SCALAR_FIELDS.filter(f => !(s[f] || '').trim());
|
||||
return `- ${s.name}${s.aliases ? ` (${s.aliases})` : ''}${s.archetype ? ` — ${s.archetype}` : ''}`
|
||||
+ (missing.length ? ` | still needs: ${missing.join(', ')}` : ' | complete');
|
||||
// Keep the summary compact — just name + archetype + which key fields are still blank.
|
||||
// Long "still needs:" lists were blowing up context windows on early passages.
|
||||
const KEY_FIELDS = ['physical', 'backstory', 'motivation', 'voice_pattern', 'relationships'];
|
||||
return [...map.values()].slice(0, 40).map(s => {
|
||||
const missing = KEY_FIELDS.filter(f => !(s[f] || '').trim());
|
||||
const suffix = missing.length < KEY_FIELDS.length ? ` | needs: ${missing.join(', ')}` : ' | complete';
|
||||
return `- ${s.name}${s.archetype ? ` (${s.archetype})` : ''}${suffix}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
function _csStr(v) {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
function csMerge(map, sheets) {
|
||||
for (const s of sheets) {
|
||||
const name = (s.name || '').trim(); if (!name) continue;
|
||||
const key = name.toLowerCase();
|
||||
if (!map.has(key)) {
|
||||
map.set(key, { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] });
|
||||
const copy = { ...s, name, inventory: [...(s.inventory || [])], sources: [...(s.sources || [])] };
|
||||
CS_SCALAR_FIELDS.forEach(f => { copy[f] = _csStr(copy[f]); });
|
||||
map.set(key, copy);
|
||||
continue;
|
||||
}
|
||||
const e = map.get(key);
|
||||
CS_SCALAR_FIELDS.forEach(f => { if ((s[f] || '').length > (e[f] || '').length) e[f] = s[f]; });
|
||||
CS_SCALAR_FIELDS.forEach(f => { const sv = _csStr(s[f]); if (sv.length > (e[f] || '').length) e[f] = sv; });
|
||||
if (s.tier === 'main') e.tier = 'main';
|
||||
if (s.moral_alignment_score != null) {
|
||||
e.moral_alignment_score = e.moral_alignment_score != null
|
||||
@ -89,30 +101,55 @@ function csMerge(map, sheets) {
|
||||
}
|
||||
}
|
||||
|
||||
async function csGenerate(text, cacheKey) {
|
||||
async function csGenerate(text, cacheKey, initialRoster) {
|
||||
if (_cs.running) return null;
|
||||
if (!text) { toast('Nothing to analyse', 'error'); return null; }
|
||||
const chunks = (typeof splitTextIntoChunks === 'function') ? splitTextIntoChunks(text, CS_CHUNK_CHARS) : [text];
|
||||
_cs.running = true; _cs.cancel = false;
|
||||
const prog = csProgress(chunks.length);
|
||||
const llm_url = csLlmUrl(), model = csLlmModel(), language = csLang();
|
||||
const llm_url = csLlmUrl(), model = csLlmModel();
|
||||
let language = csLang();
|
||||
if ((!language || /^auto$/i.test(language)) && typeof detectLang === 'function') {
|
||||
const detected = detectLang(text);
|
||||
if (detected) {
|
||||
language = detected;
|
||||
const sel = $('reh-design-lang');
|
||||
if (sel) { const o = [...sel.options].find(x => x.value.toLowerCase() === detected.toLowerCase() || x.textContent.toLowerCase() === detected.toLowerCase()); if (o) sel.value = o.value; }
|
||||
}
|
||||
}
|
||||
const map = new Map();
|
||||
const roster = [];
|
||||
// Pre-seed the roster with known characters from the casting run so the LLM
|
||||
// knows all character names from passage 1 and won't rediscover them.
|
||||
const roster = initialRoster ? [...initialRoster] : [];
|
||||
const knownTotal = roster.length || null;
|
||||
try {
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
if (_cs.cancel) break;
|
||||
prog.update(i, `Reading characters · passage ${i + 1} / ${chunks.length}…`);
|
||||
const detailed = [...map.values()].filter(s => CS_SCALAR_FIELDS.some(f => (s[f] || '').trim())).length;
|
||||
const total = knownTotal || map.size;
|
||||
const charLabel = knownTotal
|
||||
? `${detailed} / ${knownTotal} characters detailed`
|
||||
: `${map.size} characters found`;
|
||||
prog.update(i, `Passage ${i + 1} / ${chunks.length}…`, charLabel);
|
||||
try {
|
||||
const r = await fetch('/api/character-sheets', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(-40), existing: csExistingSummary(map), language, llm_url, model }),
|
||||
body: JSON.stringify({ text: chunks[i], known_characters: roster.slice(0, 60), existing: csExistingSummary(map), language, llm_url, model }),
|
||||
});
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||
const data = await r.json();
|
||||
if (!r.ok) {
|
||||
let detail = `HTTP ${r.status}`;
|
||||
try { const e = await r.json(); detail = e.detail || e.error || detail; } catch (_) {}
|
||||
throw new Error(detail);
|
||||
}
|
||||
const raw = await r.text();
|
||||
let data;
|
||||
try { data = JSON.parse(raw); }
|
||||
catch (_) { throw new Error('Invalid JSON from server (passage may be too large)'); }
|
||||
csMerge(map, data.sheets || []);
|
||||
(data.characters || []).forEach(n => { if (!roster.includes(n)) roster.push(n); });
|
||||
} catch (e) {
|
||||
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || e), 'error');
|
||||
console.error('Character sheets passage', i + 1, 'failed:', e);
|
||||
toast('Passage ' + (i + 1) + ' failed: ' + (e.message || String(e)), 'error');
|
||||
}
|
||||
}
|
||||
} finally { prog.done(); _cs.running = false; }
|
||||
@ -130,48 +167,83 @@ function csProgress(total) {
|
||||
ov.innerHTML = `<div class="audiobook-box">
|
||||
<div class="audiobook-title"><span class="mdi mdi-account-details-outline"></span> Character sheets</div>
|
||||
<div class="audiobook-msg" id="cs-progress-msg">Analysing…</div>
|
||||
<div class="reader-synth-track"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
|
||||
<div class="reader-synth-track" style="margin-bottom:4px"><div class="reader-synth-fill" id="cs-progress-fill"></div></div>
|
||||
<div class="cs-progress-chars" id="cs-progress-chars"></div>
|
||||
<div class="audiobook-actions"><button class="btn-secondary btn-sm" id="cs-progress-cancel">Cancel</button></div>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
ov.querySelector('#cs-progress-cancel').addEventListener('click', () => { _cs.cancel = true; });
|
||||
}
|
||||
ov.hidden = false;
|
||||
const fill = ov.querySelector('#cs-progress-fill'), msg = ov.querySelector('#cs-progress-msg');
|
||||
ov.style.display = 'flex';
|
||||
const fill = ov.querySelector('#cs-progress-fill');
|
||||
const msg = ov.querySelector('#cs-progress-msg');
|
||||
const chars = ov.querySelector('#cs-progress-chars');
|
||||
return {
|
||||
update(d, label) { if (fill) fill.style.width = (d / total * 100) + '%'; if (msg && label) msg.textContent = label; },
|
||||
done() { ov.hidden = true; },
|
||||
update(d, label, charLabel) {
|
||||
if (fill) fill.style.width = (d / total * 100) + '%';
|
||||
if (msg && label) msg.textContent = label;
|
||||
if (chars && charLabel) chars.textContent = charLabel;
|
||||
},
|
||||
done() { ov.style.display = 'none'; },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Alignment bar ─────────────────────────────────────────────────────────────
|
||||
|
||||
function csAlignmentBar(score, arcDirection, arcNote) {
|
||||
const pct = Math.max(0, Math.min(100, score ?? 50));
|
||||
if (score == null) return '';
|
||||
const pct = Math.max(0, Math.min(100, score));
|
||||
const arcMap = {
|
||||
'stable-good': { arrow: '→', label: 'Stable Good', color: '#c8e6c9' },
|
||||
'stable-bad': { arrow: '→', label: 'Stable Evil', color: '#777' },
|
||||
'neutral': { arrow: '→', label: 'Neutral', color: '#aaa' },
|
||||
'good-to-bad': { arrow: '↘', label: 'Descends', color: '#ff7043' },
|
||||
'bad-to-good': { arrow: '↗', label: 'Redeems', color: '#66bb6a' },
|
||||
'stable-good': { arrow: '→', label: 'Stable Good', color: '#66bb6a' },
|
||||
'stable-bad': { arrow: '→', label: 'Stable Evil', color: '#aaa' },
|
||||
'neutral': { arrow: '→', label: 'Neutral', color: '#aaa' },
|
||||
'good-to-bad': { arrow: '↘', label: 'Descends toward evil', color: '#ff7043' },
|
||||
'bad-to-good': { arrow: '↗', label: 'Redeems toward good', color: '#66bb6a' },
|
||||
'complex': { arrow: '↕', label: 'Complex arc', color: '#ab47bc' },
|
||||
};
|
||||
const arc = arcMap[arcDirection] || arcMap['neutral'];
|
||||
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Neutral/Ambiguous';
|
||||
return `<div class="cs-alignment-wrap" title="${escHtml(arcNote || '')}">
|
||||
<div class="cs-alignment-labels"><span>◼ Evil</span><span>Good ◻</span></div>
|
||||
<div class="cs-alignment-bar">
|
||||
<div class="cs-alignment-track"></div>
|
||||
<div class="cs-alignment-marker" style="left:${pct}%" title="${pct}/100 — ${label}"></div>
|
||||
</div>
|
||||
<div class="cs-arc-row">
|
||||
<span class="cs-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>
|
||||
<span class="cs-arc-label" style="color:${arc.color}">${escHtml(arc.label)}</span>
|
||||
${arcNote ? `<span class="cs-arc-note">${escHtml(arcNote)}</span>` : ''}
|
||||
const showArrow = arcDirection && arcDirection !== 'neutral';
|
||||
return `<div class="cs-alignment-wrap">
|
||||
<div class="cs-alignment-labels"><span>Evil</span><span>Good</span></div>
|
||||
<div class="cs-alignment-gradient" title="${label} (${pct}/100)${arcNote ? ' — ' + arcNote : ''}">
|
||||
<div class="cs-alignment-dot" style="left:${pct}%">
|
||||
${showArrow ? `<span class="cs-alignment-arc-arrow" style="color:${arc.color}" title="${escHtml(arc.label)}">${arc.arrow}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
${arcNote ? `<div class="cs-arc-note">${escHtml(arc.label)} · <em>${escHtml(arcNote)}</em></div>` : `<div class="cs-arc-note" style="color:${arc.color}">${escHtml(arc.label)}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Image & Voice prompt generators ──────────────────────────────────────────
|
||||
|
||||
function csBuildImagePrompt(s) {
|
||||
const parts = [];
|
||||
if (s.archetype) parts.push(s.archetype);
|
||||
if (s.gender) parts.push(`${s.gender} character`);
|
||||
if (s.physical) parts.push(s.physical);
|
||||
if (s.clothing) parts.push(s.clothing);
|
||||
if (s.alignment) parts.push(s.alignment);
|
||||
const pct = s.moral_alignment_score ?? 50;
|
||||
parts.push(pct >= 70 ? 'benevolent expression' : pct <= 30 ? 'dark and menacing presence' : 'ambiguous expression');
|
||||
if (s.arc_direction === 'bad-to-good') parts.push('redemptive aura');
|
||||
if (s.arc_direction === 'good-to-bad') parts.push('ominous aura, turning to darkness');
|
||||
return `portrait of ${escHtml(s.name)}, ${parts.filter(Boolean).join(', ')}, fantasy character art, detailed face, dramatic lighting, high detail`;
|
||||
}
|
||||
|
||||
function csBuildVoicePrompt(s) {
|
||||
const parts = [];
|
||||
if (s.voice_pattern) parts.push(s.voice_pattern);
|
||||
if (s.mannerisms) parts.push(s.mannerisms);
|
||||
if (s.archetype) parts.push(`archetype: ${s.archetype}`);
|
||||
if (s.gender) parts.push(`gender: ${s.gender}`);
|
||||
const pct = s.moral_alignment_score ?? 50;
|
||||
if (pct >= 70) parts.push('warm, trustworthy tone');
|
||||
else if (pct <= 30) parts.push('cold, threatening or sinister tone');
|
||||
else parts.push('neutral, measured tone');
|
||||
return parts.filter(Boolean).join('. ');
|
||||
}
|
||||
|
||||
// ── Deep analysis modal ───────────────────────────────────────────────────────
|
||||
|
||||
async function csDeepAnalysis(sheet, sourceText) {
|
||||
@ -239,6 +311,20 @@ function csField(label, value) {
|
||||
return `<div class="cs-row"><dt>${label}</dt><dd>${escHtml(String(value))}</dd></div>`;
|
||||
}
|
||||
|
||||
function csAvatarHtml(s) {
|
||||
const hue = Math.abs((s.name || '?').split('').reduce((h, c) => (h * 31 + c.charCodeAt(0)) % 360, 0));
|
||||
if (s._image) {
|
||||
return `<div class="cs-avatar cs-avatar-img" data-name="${escHtml(s.name)}" title="Click to change profile picture">
|
||||
<img src="${s._image}" alt="${escHtml(s.name)}">
|
||||
<div class="cs-avatar-overlay"><span class="mdi mdi-camera"></span></div>
|
||||
</div>`;
|
||||
}
|
||||
return `<div class="cs-avatar cs-avatar-letter" style="background:hsl(${hue},50%,35%)" data-name="${escHtml(s.name)}" title="Click to upload profile picture">
|
||||
<span>${escHtml((s.name || '?')[0].toUpperCase())}</span>
|
||||
<div class="cs-avatar-overlay"><span class="mdi mdi-camera"></span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function csCardHtml(s) {
|
||||
const inv = (s.inventory || []).filter(Boolean);
|
||||
const invHtml = inv.length
|
||||
@ -259,18 +345,23 @@ function csCardHtml(s) {
|
||||
: '';
|
||||
|
||||
return `<div class="cs-card cs-${s.tier === 'main' ? 'main' : 'supp'}" data-name="${escHtml(s.name)}">
|
||||
<div class="cs-head">
|
||||
<span class="cs-name">${escHtml(s.name)}</span>
|
||||
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
||||
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</span>
|
||||
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}"
|
||||
title="5-area psychological deep analysis"
|
||||
style="margin-left:auto;font-size:11px;padding:2px 8px;">
|
||||
<span class="mdi mdi-brain"></span> Deep Analysis
|
||||
</button>
|
||||
<div class="cs-card-top">
|
||||
${csAvatarHtml(s)}
|
||||
<div class="cs-card-top-body">
|
||||
<div class="cs-head">
|
||||
<span class="cs-name">${escHtml(s.name)}</span>
|
||||
${s.archetype ? `<span class="cs-archetype">${escHtml(s.archetype)}</span>` : ''}
|
||||
<span class="cs-tier">${s.tier === 'main' ? 'Main' : 'Supporting'}</span>
|
||||
<div class="cs-head-btns">
|
||||
<button class="btn-secondary btn-sm cs-img-btn" data-name="${escHtml(s.name)}" title="Copy image-gen prompt for ComfyUI / SD"><span class="mdi mdi-image-outline"></span> Img prompt</button>
|
||||
<button class="btn-secondary btn-sm cs-voice-btn" data-name="${escHtml(s.name)}" title="Copy voice design prompt"><span class="mdi mdi-account-voice"></span> Voice prompt</button>
|
||||
<button class="btn-secondary btn-sm cs-deep-btn" data-name="${escHtml(s.name)}" title="5-area psychological deep analysis"><span class="mdi mdi-brain"></span> Deep</button>
|
||||
</div>
|
||||
</div>
|
||||
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
|
||||
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
|
||||
</div>
|
||||
</div>
|
||||
${s.aliases ? `<div class="cs-aliases">aka ${escHtml(s.aliases)}</div>` : ''}
|
||||
${csAlignmentBar(s.moral_alignment_score, s.arc_direction, s.arc_note)}
|
||||
<dl class="cs-fields">
|
||||
${csField('Physical', s.physical)}
|
||||
${csField('Clothing & Appearance', s.clothing)}
|
||||
@ -326,8 +417,22 @@ function csToMarkdown(sheets) {
|
||||
return md.trim();
|
||||
}
|
||||
|
||||
function csShow(sheets, title, sourceText) {
|
||||
async function csShow(sheets, title, sourceText, book) {
|
||||
document.getElementById('cs-overlay')?.remove();
|
||||
|
||||
// Enrich sheets with stored profile images from the character library.
|
||||
if (typeof clGetAll === 'function') {
|
||||
try {
|
||||
const bk = book || title || '';
|
||||
const all = await clGetAll();
|
||||
const imgMap = new Map(all.filter(r => r.image).map(r => [r.id, r.image]));
|
||||
sheets.forEach(s => {
|
||||
const id = `${bk}::${s.name}`.toLowerCase();
|
||||
if (imgMap.has(id)) s._image = imgMap.get(id);
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const ov = document.createElement('div');
|
||||
ov.id = 'cs-overlay'; ov.className = 'audiobook-overlay';
|
||||
const main = sheets.filter(s => s.tier === 'main');
|
||||
@ -355,15 +460,67 @@ function csShow(sheets, title, sourceText) {
|
||||
});
|
||||
ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
|
||||
|
||||
// Wire avatar upload (click on avatar → file picker → save to library)
|
||||
const bkCtx = book || title || '';
|
||||
ov.querySelectorAll('.cs-avatar').forEach(av => {
|
||||
av.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const name = av.dataset.name;
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*';
|
||||
inp.onchange = async () => {
|
||||
const file = inp.files[0]; if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = async ev => {
|
||||
const dataUrl = ev.target.result;
|
||||
const id = `${bkCtx}::${name}`.toLowerCase();
|
||||
if (typeof clSetImage === 'function') await clSetImage(id, dataUrl);
|
||||
// Update in-memory sheet and re-render avatar
|
||||
const s = sheets.find(x => x.name === name);
|
||||
if (s) s._image = dataUrl;
|
||||
const card = ov.querySelector(`.cs-card[data-name="${CSS.escape(name)}"]`);
|
||||
if (card) {
|
||||
const oldAv = card.querySelector('.cs-avatar');
|
||||
if (oldAv) { oldAv.outerHTML = csAvatarHtml(s); card.querySelector('.cs-avatar').addEventListener('click', av.onclick); }
|
||||
}
|
||||
toast('Profile picture saved', 'success');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
inp.click();
|
||||
});
|
||||
});
|
||||
|
||||
// Wire Deep Analysis buttons
|
||||
ov.querySelectorAll('.cs-deep-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const name = btn.dataset.name;
|
||||
const sheet = sheets.find(s => s.name === name);
|
||||
const sheet = sheets.find(s => s.name === btn.dataset.name);
|
||||
if (sheet) csDeepAnalysis(sheet, sourceText);
|
||||
});
|
||||
});
|
||||
|
||||
// Wire Image prompt buttons
|
||||
ov.querySelectorAll('.cs-img-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const sheet = sheets.find(s => s.name === btn.dataset.name);
|
||||
if (!sheet) return;
|
||||
navigator.clipboard?.writeText(csBuildImagePrompt(sheet))
|
||||
.then(() => toast('Image prompt copied', 'success'), () => toast('Copy failed', 'error'));
|
||||
});
|
||||
});
|
||||
|
||||
// Wire Voice prompt buttons
|
||||
ov.querySelectorAll('.cs-voice-btn').forEach(btn => {
|
||||
btn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
const sheet = sheets.find(s => s.name === btn.dataset.name);
|
||||
if (!sheet) return;
|
||||
navigator.clipboard?.writeText(csBuildVoicePrompt(sheet))
|
||||
.then(() => toast('Voice prompt copied — paste into Design a Voice', 'success'), () => toast('Copy failed', 'error'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── Entry points (reader + rehearser) ────────────────────────────────────────
|
||||
@ -377,30 +534,42 @@ async function csSaveToLibrary(book, sheets) {
|
||||
} catch (e) { /* non-fatal — the overlay still works */ }
|
||||
}
|
||||
|
||||
function csGoToLibrary() {
|
||||
if (typeof navTo === 'function') navTo('s-library');
|
||||
if (typeof navLibraryView === 'function') navLibraryView('characters');
|
||||
else if (typeof libraryRender === 'function') libraryRender('characters');
|
||||
}
|
||||
|
||||
async function csForReader() {
|
||||
const text = csReaderText();
|
||||
const book = readerState.title || 'Untitled book';
|
||||
const key = 'reader:' + (readerState.title || '') + ':' + (typeof readerScopeIndices === 'function' ? readerScopeIndices().length : 0);
|
||||
if (_cs.cache[key]) { csShow(_cs.cache[key], readerState.title || 'Character sheets', text); return; }
|
||||
const sheets = await csGenerate(text, key);
|
||||
if (_cs.cache[key]) { csGoToLibrary(); return; }
|
||||
const knownRoster = (window._audiobook && _audiobook.roster && _audiobook.roster.length)
|
||||
? _audiobook.roster.slice() : null;
|
||||
const sheets = await csGenerate(text, key, knownRoster);
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
csSaveToLibrary(book, sheets);
|
||||
csShow(sheets, readerState.title || 'Character sheets', text);
|
||||
await csSaveToLibrary(book, sheets);
|
||||
csGoToLibrary();
|
||||
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
||||
}
|
||||
|
||||
async function csForRehearser() {
|
||||
const title = $('reh-script-title')?.value.trim() || 'Character sheets';
|
||||
const book = $('reh-script-title')?.value.trim() || 'Untitled script';
|
||||
const text = csRehearserText();
|
||||
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||
if (_cs.cache[key]) { csShow(_cs.cache[key], title, text); return; }
|
||||
const book = $('reh-script-title')?.value.trim() || 'Untitled script';
|
||||
const text = csRehearserText();
|
||||
const key = 'reh:' + title + ':' + ((rehState.lines || []).length);
|
||||
if (_cs.cache[key]) { csGoToLibrary(); return; }
|
||||
const sheets = await csGenerate(text, key);
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
csSaveToLibrary(book, sheets);
|
||||
csShow(sheets, title, text);
|
||||
await csSaveToLibrary(book, sheets);
|
||||
csGoToLibrary();
|
||||
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
||||
}
|
||||
|
||||
window.csForReader = csForReader;
|
||||
window.csForRehearser = csForRehearser;
|
||||
$('reader-charsheets-btn')?.addEventListener('click', csForReader);
|
||||
$('reh-charsheets-btn')?.addEventListener('click', csForRehearser);
|
||||
|
||||
@ -1,12 +1,8 @@
|
||||
// ── Character library ─────────────────────────────────────────────────────────
|
||||
//
|
||||
// Persistent, book-scoped character sheets. Running "Character sheets" in Read
|
||||
// Aloud or the Rehearser upserts each extracted character into this library,
|
||||
// keyed by (book + name). Cards reuse the renderers from character-sheets.js
|
||||
// (csCardHtml, csAlignmentBar, csDeepAnalysis), which loads before this module.
|
||||
|
||||
const CL_DB_NAME = 'character-library';
|
||||
const CL_STORE = 'characters';
|
||||
// Persistent, book-scoped character sheets stored in SQLite via /api/characters.
|
||||
// All function signatures are unchanged from the IndexedDB version so callers
|
||||
// (audiobook.js, character-sheets.js, etc.) need no edits.
|
||||
|
||||
// Fields a user can edit, mirroring CS_SCALAR_FIELDS plus the labelled basics.
|
||||
const CL_EDIT_FIELDS = [
|
||||
@ -20,58 +16,65 @@ const CL_EDIT_FIELDS = [
|
||||
['conflict_style', 'Conflict Style'], ['win_condition', 'Win Condition'],
|
||||
];
|
||||
|
||||
// ── IndexedDB ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function clDbOpen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(CL_DB_NAME, 1);
|
||||
req.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(CL_STORE)) {
|
||||
const store = db.createObjectStore(CL_STORE, { keyPath: 'id' });
|
||||
store.createIndex('book', 'book', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = e => resolve(e.target.result);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function clDbOp(mode, fn) {
|
||||
const db = await clDbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(CL_STORE, mode);
|
||||
const req = fn(tx.objectStore(CL_STORE));
|
||||
req.onsuccess = e => resolve(e.target.result);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
// ── Server API ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function clGetAll() {
|
||||
const db = await clDbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = db.transaction(CL_STORE, 'readonly').objectStore(CL_STORE).getAll();
|
||||
req.onsuccess = e => resolve(e.target.result || []);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
const r = await fetch('/api/characters');
|
||||
if (!r.ok) throw new Error('clGetAll failed: ' + r.status);
|
||||
const d = await r.json();
|
||||
return d.characters || [];
|
||||
}
|
||||
|
||||
async function clGet(id) { return clDbOp('readonly', s => s.get(id)); }
|
||||
async function clPut(rec) { return clDbOp('readwrite', s => s.put(rec)); }
|
||||
async function clDelete(id) { return clDbOp('readwrite', s => s.delete(id)); }
|
||||
async function clGet(id) {
|
||||
const r = await fetch('/api/characters/' + encodeURIComponent(id));
|
||||
if (r.status === 404) return undefined;
|
||||
if (!r.ok) throw new Error('clGet failed: ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function clPut(rec) {
|
||||
const r = await fetch('/api/characters/' + encodeURIComponent(rec.id), {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(rec),
|
||||
});
|
||||
if (!r.ok) throw new Error('clPut failed: ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function clDelete(id) {
|
||||
const r = await fetch('/api/characters/' + encodeURIComponent(id), { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error('clDelete failed: ' + r.status);
|
||||
}
|
||||
|
||||
function clKey(book, name) {
|
||||
return `${String(book || '').trim()}::${String(name || '').trim()}`.toLowerCase();
|
||||
}
|
||||
|
||||
// Merge an incoming sheet into an existing one without blanking solid facts —
|
||||
// same rule as csMerge in character-sheets.js (longer value wins; sources append;
|
||||
// alignment score averages).
|
||||
// Coerce a sheet field value to string, matching _csStr in character-sheets.js.
|
||||
function _clStr(v) {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
// Sanitize all scalar string fields on a raw sheet object before storing.
|
||||
function _clSanitize(sheet) {
|
||||
const out = { ...sheet };
|
||||
const fields = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
|
||||
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
|
||||
fields.forEach(f => { if (out[f] != null) out[f] = _clStr(out[f]); });
|
||||
return out;
|
||||
}
|
||||
|
||||
// Merge an incoming sheet into an existing one without blanking solid facts.
|
||||
function clMergeSheet(existing, incoming) {
|
||||
const e = { ...existing };
|
||||
const inc = _clSanitize(incoming);
|
||||
const scalars = (typeof CS_SCALAR_FIELDS !== 'undefined') ? CS_SCALAR_FIELDS
|
||||
: CL_EDIT_FIELDS.map(f => f[0]).filter(k => k !== 'name');
|
||||
scalars.forEach(f => { if ((incoming[f] || '').length > (e[f] || '').length) e[f] = incoming[f]; });
|
||||
scalars.forEach(f => { if ((inc[f] || '').length > (e[f] || '').length) e[f] = inc[f]; });
|
||||
if (incoming.tier === 'main') e.tier = 'main';
|
||||
if (incoming.moral_alignment_score != null) {
|
||||
e.moral_alignment_score = e.moral_alignment_score != null
|
||||
@ -88,6 +91,13 @@ function clMergeSheet(existing, incoming) {
|
||||
return e;
|
||||
}
|
||||
|
||||
// Union a character's tag strings into one clean comma-separated list.
|
||||
function clMergeTags(...parts) {
|
||||
const set = new Set();
|
||||
parts.forEach(p => String(p || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t)));
|
||||
return [...set].join(', ');
|
||||
}
|
||||
|
||||
// Upsert one sheet into the library under a book. Returns the stored record.
|
||||
async function clUpsert(book, sheet) {
|
||||
const name = (sheet.name || '').trim();
|
||||
@ -96,29 +106,31 @@ async function clUpsert(book, sheet) {
|
||||
const id = clKey(bk, name);
|
||||
const now = new Date();
|
||||
const prev = await clGet(id).catch(() => null);
|
||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sheet, name };
|
||||
// Tags (comma-separated, like voices) — the productions this character belongs
|
||||
// to. Seeded with its origin book; accumulates without ever blanking so a
|
||||
// character can be reused across several books/scripts.
|
||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ..._clSanitize(sheet), name };
|
||||
const tags = clMergeTags(prev?.tags, sheet.tags, bk);
|
||||
const rec = {
|
||||
id, book: bk, name, tags,
|
||||
sheet: merged,
|
||||
analysis: prev?.analysis || null,
|
||||
voice: prev?.voice || sheet.voice || null,
|
||||
created: prev?.created || now,
|
||||
updated: now,
|
||||
voice: prev?.voice || sheet.voice || null,
|
||||
image: prev?.image || sheet.image || null,
|
||||
created: prev?.created || now,
|
||||
updated: now,
|
||||
};
|
||||
await clPut(rec);
|
||||
return rec;
|
||||
}
|
||||
|
||||
// Union a character's tag strings into one clean comma-separated list.
|
||||
function clMergeTags(...parts) {
|
||||
const set = new Set();
|
||||
parts.forEach(p => String(p || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t)));
|
||||
return [...set].join(', ');
|
||||
// Save (or clear) a profile image for a character by its record id.
|
||||
async function clSetImage(id, dataUrl) {
|
||||
const rec = await clGet(id).catch(() => null);
|
||||
if (!rec) return;
|
||||
rec.image = dataUrl || null;
|
||||
rec.updated = new Date();
|
||||
await clPut(rec);
|
||||
return rec;
|
||||
}
|
||||
window.clSetImage = clSetImage;
|
||||
|
||||
// All characters belonging to a production, by origin book OR by tag membership.
|
||||
async function clGetAllByTagOrBook(title) {
|
||||
@ -131,13 +143,52 @@ async function clGetAllByTagOrBook(title) {
|
||||
});
|
||||
}
|
||||
|
||||
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
|
||||
// Bulk upsert a list of sheets for one book.
|
||||
async function clUpsertMany(book, sheets) {
|
||||
let n = 0;
|
||||
for (const s of (sheets || [])) { if (await clUpsert(book, s)) n++; }
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── One-time IndexedDB → server migration ────────────────────────────────────
|
||||
// Runs once after a successful clGetAll() that returns an empty server. Reads
|
||||
// the old IndexedDB store (if present) and POSTs all records to /api/characters/migrate.
|
||||
|
||||
(async function _clMigrateIfNeeded() {
|
||||
try {
|
||||
const serverRecs = await clGetAll();
|
||||
if (serverRecs.length > 0) return; // already migrated
|
||||
const idbRecs = await _clIdbGetAll().catch(() => []);
|
||||
if (!idbRecs.length) return;
|
||||
const r = await fetch('/api/characters/migrate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(idbRecs),
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
console.log(`[characters-library] migrated ${d.imported} records from IndexedDB → SQLite`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[characters-library] migration skipped:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
function _clIdbGetAll() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('character-library', 1);
|
||||
req.onerror = () => resolve([]);
|
||||
req.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('characters')) { db.close(); resolve([]); return; }
|
||||
const tx = db.transaction('characters', 'readonly');
|
||||
const all = tx.objectStore('characters').getAll();
|
||||
all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); };
|
||||
all.onerror = () => { db.close(); resolve([]); };
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ── Rendering ─────────────────────────────────────────────────────────────────
|
||||
|
||||
let _clRecords = [];
|
||||
@ -145,15 +196,12 @@ let _clRecords = [];
|
||||
async function clRender() {
|
||||
const grid = document.getElementById('cl-grid');
|
||||
if (!grid) return;
|
||||
// Bind filter/search once the section fragment exists (it loads after this JS).
|
||||
const filterEl = document.getElementById('cl-book-filter');
|
||||
const searchEl = document.getElementById('cl-search');
|
||||
if (filterEl && !filterEl.dataset.bound) { filterEl.dataset.bound = '1'; filterEl.addEventListener('change', clApplyFilter); }
|
||||
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
|
||||
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
|
||||
|
||||
// Populate the production filter with the union of origin books and all tags
|
||||
// (preserve current selection if still present).
|
||||
const filter = document.getElementById('cl-book-filter');
|
||||
const prods = clAllProductions();
|
||||
if (filter) {
|
||||
@ -165,7 +213,6 @@ async function clRender() {
|
||||
clApplyFilter();
|
||||
}
|
||||
|
||||
// Distinct production names across origin books and character tags.
|
||||
function clAllProductions() {
|
||||
const set = new Set();
|
||||
_clRecords.forEach(r => {
|
||||
@ -204,7 +251,6 @@ function clApplyFilter() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by book.
|
||||
const byBook = new Map();
|
||||
for (const r of recs) { if (!byBook.has(r.book)) byBook.set(r.book, []); byBook.get(r.book).push(r); }
|
||||
const groups = [...byBook.keys()].sort((a, b) => a.localeCompare(b));
|
||||
@ -285,10 +331,8 @@ function clEdit(id) {
|
||||
let mas = parseInt(patch.moral_alignment_score, 10);
|
||||
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
|
||||
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: mas };
|
||||
// Record-level fields (tags) live outside the sheet.
|
||||
const tagsEl = modal.querySelector('[data-rec-field="tags"]');
|
||||
const newTags = tagsEl ? clMergeTags(tagsEl.value, rec.book) : (rec.tags || rec.book);
|
||||
// If the name changed, the key changes too — delete old, write new.
|
||||
const newId = clKey(rec.book, newName);
|
||||
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.updated = new Date();
|
||||
try {
|
||||
@ -311,8 +355,6 @@ async function clDeleteRec(id) {
|
||||
|
||||
// ── Wiring ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// Document-level delegation — the #cl-grid is (re)built on every render and the
|
||||
// section fragment loads after this script, so we can't bind to it directly.
|
||||
document.addEventListener('click', e => {
|
||||
const edit = e.target.closest?.('.cl-edit');
|
||||
if (edit) { e.stopPropagation(); clEdit(edit.dataset.id); return; }
|
||||
|
||||
@ -61,13 +61,14 @@ document.querySelectorAll('.s-log-filter').forEach(btn => {
|
||||
|
||||
// ── Settings: About ───────────────────────────────────────────────────────
|
||||
|
||||
// Fetch and display the server version in the About page
|
||||
(async function loadAppVersion() {
|
||||
try {
|
||||
const d = await fetch('/api/version').then(r => r.json());
|
||||
const el = $('s-about-version');
|
||||
// Show version immediately from meta tag, then confirm from server
|
||||
(function loadAppVersion() {
|
||||
const el = $('s-about-version');
|
||||
const metaVer = document.querySelector('meta[name="app-version"]')?.content;
|
||||
if (el && metaVer) el.textContent = 'v' + metaVer;
|
||||
fetch('/api/version').then(r => r.json()).then(d => {
|
||||
if (el && d.version) el.textContent = 'v' + d.version;
|
||||
} catch (_) {}
|
||||
}).catch(() => {});
|
||||
})();
|
||||
|
||||
// Lazy-load changelog when the details element is opened
|
||||
|
||||
@ -306,15 +306,28 @@ function cardType(card) {
|
||||
return page ? page.dataset.page : '';
|
||||
}
|
||||
|
||||
function _dcSkel(n = 2) {
|
||||
const card = () => `<div class="dc-skel-card">
|
||||
<div class="dc-skel-line tall"></div>
|
||||
<div class="dc-skel-line wide"></div>
|
||||
<div class="dc-skel-line medium"></div>
|
||||
<div style="display:flex;gap:6px">
|
||||
<div class="dc-skel-line short" style="flex:1"></div>
|
||||
<div class="dc-skel-line short" style="flex:1"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
return Array.from({ length: n }, card).join('');
|
||||
}
|
||||
|
||||
async function loadLocalContainers() {
|
||||
const gridTts = $('dc-grid-tts');
|
||||
const gridStt = $('dc-grid-stt');
|
||||
const gridLlm = $('dc-grid-llm');
|
||||
if (!gridTts && !gridStt && !gridLlm) return;
|
||||
const loading = '<div class="dc-loading">Checking container status…</div>';
|
||||
if (gridTts) gridTts.innerHTML = loading;
|
||||
if (gridStt) gridStt.innerHTML = loading;
|
||||
if (gridLlm) gridLlm.innerHTML = loading;
|
||||
const skel = _dcSkel(2);
|
||||
if (gridTts) gridTts.innerHTML = skel;
|
||||
if (gridStt) gridStt.innerHTML = skel;
|
||||
if (gridLlm) gridLlm.innerHTML = skel;
|
||||
const custom = loadCustomEngineCards();
|
||||
try {
|
||||
const r = await fetch('/api/local-containers');
|
||||
|
||||
@ -40,14 +40,25 @@ window.libraryRender = function (view) {
|
||||
});
|
||||
if (view === 'books') libraryRenderBooks();
|
||||
else if (view === 'plays') libraryRenderPlays();
|
||||
else if (view === 'characters' && typeof clRender === 'function') clRender();
|
||||
else if (view === 'characters') libraryRenderCharacters();
|
||||
};
|
||||
|
||||
// ── Books (server-side reader library) ────────────────────────────────────────
|
||||
|
||||
function _libSkeleton(n) {
|
||||
return Array.from({ length: n }, () =>
|
||||
'<div class="reh-book lib-book-sk" style="pointer-events:none">'
|
||||
+ '<div class="sk" style="height:14px;width:70%;margin-bottom:8px;border-radius:4px"></div>'
|
||||
+ '<div class="sk" style="height:10px;width:45%;border-radius:4px;margin-bottom:6px"></div>'
|
||||
+ '<div class="sk" style="height:6px;width:90%;border-radius:3px"></div>'
|
||||
+ '</div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
async function libraryRenderBooks() {
|
||||
const list = document.getElementById('lib-books-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _libSkeleton(4);
|
||||
let all = [];
|
||||
try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
|
||||
if (!all.length) {
|
||||
@ -83,6 +94,11 @@ async function libraryRenderBooks() {
|
||||
const id = el.dataset.id, title = el.dataset.title;
|
||||
el.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.reh-book-act')) return;
|
||||
// Show the reader on the PDF view immediately and suppress the stale-cast
|
||||
// restore so a freshly-opened book never lands in an empty casting panel.
|
||||
window._readerStartView = 'main';
|
||||
window._readerSuppressCastRestore = true;
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id);
|
||||
});
|
||||
const reh = el.querySelector('.lib-act-rehearse');
|
||||
@ -103,6 +119,7 @@ async function libraryRenderBooks() {
|
||||
async function libraryRenderPlays() {
|
||||
const list = document.getElementById('lib-plays-list');
|
||||
if (!list) return;
|
||||
list.innerHTML = _libSkeleton(3);
|
||||
let all = [];
|
||||
try { all = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : []; } catch (_) { all = []; }
|
||||
if (!all.length) {
|
||||
@ -292,6 +309,922 @@ function libConfirmDelete(cardEl, heading, sub, onConfirm) {
|
||||
cardEl.appendChild(o);
|
||||
}
|
||||
|
||||
// ── Characters / Cast ─────────────────────────────────────────────────────────
|
||||
|
||||
async function libraryRenderCharacters() {
|
||||
const container = document.getElementById('lib-chars-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="lib-chars-loading"><span class="mdi mdi-loading mdi-spin"></span> Loading characters…</div>';
|
||||
|
||||
let all = [];
|
||||
try { all = (typeof clGetAll === 'function') ? await clGetAll() : []; } catch (_) { all = []; }
|
||||
|
||||
if (!all.length) {
|
||||
container.innerHTML = '<div class="lib-chars-toolbar">'
|
||||
+ '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>'
|
||||
+ '</div>'
|
||||
+ '<div class="lib-empty">'
|
||||
+ '<span class="mdi mdi-account-box-multiple-outline"></span>'
|
||||
+ '<p>No characters yet.</p>'
|
||||
+ '<p class="lib-empty-hint">Open a book in <b>Read Aloud</b>, cast it as an audiobook, then click <b>Cast Characters</b> to generate character sheets — or import an existing cast from SillyTavern.</p>'
|
||||
+ '</div>';
|
||||
container.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Group by book (production)
|
||||
const byBook = {};
|
||||
all.forEach(function (rec) {
|
||||
const bk = rec.book || 'Unsorted';
|
||||
if (!byBook[bk]) byBook[bk] = [];
|
||||
byBook[bk].push(rec);
|
||||
});
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Global toolbar — import a cast from SillyTavern into a new/unsorted production
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'lib-chars-toolbar';
|
||||
bar.innerHTML = '<button class="btn-secondary btn-sm" id="lib-chars-import" title="Import character cards from SillyTavern (.json or .png)"><span class="mdi mdi-import"></span> Import from SillyTavern</button>';
|
||||
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
|
||||
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
|
||||
});
|
||||
container.appendChild(bar);
|
||||
|
||||
Object.keys(byBook).sort().forEach(function (book) {
|
||||
const chars = byBook[book].sort(function (a, b) {
|
||||
const tierOrder = { main: 0, supporting: 1, minor: 2 };
|
||||
const ta = tierOrder[String(a.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||||
const tb = tierOrder[String(b.sheet?.tier || 'minor').toLowerCase()] ?? 2;
|
||||
return ta - tb || (a.name || '').localeCompare(b.name || '');
|
||||
});
|
||||
|
||||
const cov = libBookCover(book);
|
||||
const prod = document.createElement('div');
|
||||
prod.className = 'lib-chars-production';
|
||||
prod.innerHTML = '<div class="lib-chars-prod-head" style="--pk1:' + cov.c1 + ';--pk2:' + cov.c2 + '">'
|
||||
+ '<div class="lib-chars-prod-title"><span class="mdi mdi-bookshelf"></span> ' + escHtml(book) + '</div>'
|
||||
+ '<div class="lib-chars-prod-actions">'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-cast-btn" data-book="' + escHtml(book) + '" title="Re-run character sheet generation"><span class="mdi mdi-account-details-outline"></span> Cast Characters</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-reh-btn" data-book="' + escHtml(book) + '" title="Open in Script Rehearsal"><span class="mdi mdi-theater"></span> Rehearse</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-read-btn" data-book="' + escHtml(book) + '" title="Open in Read Aloud"><span class="mdi mdi-book-open-page-variant-outline"></span> Read Aloud</button>'
|
||||
+ '<button class="btn-secondary btn-sm lib-chars-imp-btn" data-book="' + escHtml(book) + '" title="Import SillyTavern cards into this production"><span class="mdi mdi-import"></span></button>'
|
||||
+ '</div></div>'
|
||||
+ '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
||||
+ chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
||||
+ '</div>';
|
||||
|
||||
// Action buttons
|
||||
prod.querySelector('.lib-chars-cast-btn').addEventListener('click', function () {
|
||||
if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
|
||||
toast('Open the book in Read Aloud then click Cast Characters', 'info');
|
||||
});
|
||||
prod.querySelector('.lib-chars-reh-btn').addEventListener('click', function () {
|
||||
if (typeof productionOpenInRehearser === 'function') productionOpenInRehearser(book);
|
||||
});
|
||||
prod.querySelector('.lib-chars-read-btn').addEventListener('click', function () {
|
||||
if (typeof productionOpenInReader === 'function') productionOpenInReader(book);
|
||||
});
|
||||
prod.querySelector('.lib-chars-imp-btn').addEventListener('click', function () {
|
||||
if (typeof stImportDialog === 'function') stImportDialog(book, function () { libraryRenderCharacters(); });
|
||||
});
|
||||
|
||||
// Wire voice selectors and auto-assign buttons
|
||||
prod.querySelectorAll('.lib-char-card').forEach(function (card) {
|
||||
const charId = card.dataset.charId;
|
||||
const rec = all.find(function (r) { return r.id === charId; });
|
||||
if (!rec) return;
|
||||
|
||||
// Click on card body → open detail page (not if clicking a button or avatar)
|
||||
card.addEventListener('click', function (e) {
|
||||
if (e.target.closest('button, .lib-char-avatar, .lib-voice-picker-popup')) return;
|
||||
_charDetailPage(rec, chars);
|
||||
});
|
||||
|
||||
// Avatar click → upload profile picture
|
||||
card.querySelector('.lib-char-avatar')?.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*';
|
||||
inp.onchange = async function () {
|
||||
const file = inp.files[0]; if (!file) return;
|
||||
const fr = new FileReader();
|
||||
fr.onload = async function (ev) {
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||||
toast('Profile picture saved', 'success');
|
||||
libraryRenderCharacters();
|
||||
};
|
||||
fr.readAsDataURL(file);
|
||||
};
|
||||
inp.click();
|
||||
});
|
||||
|
||||
card.querySelector('.lib-char-pick-voice')?.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_openVoicePicker(card, rec, function () { libraryRenderCharacters(); });
|
||||
});
|
||||
|
||||
card.querySelector('.lib-char-auto-voice')?.addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
await _autoAssignVoice(rec);
|
||||
libraryRenderCharacters();
|
||||
});
|
||||
|
||||
card.querySelector('.lib-char-export')?.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (typeof stExportRecord === 'function') stExportRecord(rec);
|
||||
});
|
||||
|
||||
card.querySelector('.lib-char-online-voice')?.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_charSearchOnline(rec);
|
||||
});
|
||||
|
||||
card.querySelector('.lib-char-gen-voice')?.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_charDesignVoice(rec);
|
||||
});
|
||||
});
|
||||
|
||||
container.appendChild(prod);
|
||||
});
|
||||
}
|
||||
|
||||
function _charHue(name) {
|
||||
return Math.abs((name || '?').split('').reduce(function (h, c) { return (h * 31 + c.charCodeAt(0)) % 360; }, 0));
|
||||
}
|
||||
|
||||
function _charAlignHtml(sh) {
|
||||
const score = sh.moral_alignment_score;
|
||||
if (score == null) return '';
|
||||
const pct = Math.max(0, Math.min(100, score));
|
||||
const arc = sh.arc_direction || 'neutral';
|
||||
const arrowMap = {
|
||||
'good-to-bad': { ch: '↘', color: '#ff7043', tip: 'Arc: Descends toward evil' },
|
||||
'bad-to-good': { ch: '↗', color: '#66bb6a', tip: 'Arc: Redeems toward good' },
|
||||
'complex': { ch: '↕', color: '#ab47bc', tip: 'Arc: Complex / unpredictable' },
|
||||
'stable-good': { ch: '→', color: '#66bb6a', tip: 'Arc: Stable good' },
|
||||
'stable-bad': { ch: '→', color: '#888', tip: 'Arc: Stable evil' },
|
||||
'neutral': { ch: '→', color: '#aaa', tip: 'Arc: Neutral' },
|
||||
};
|
||||
const a = arrowMap[arc] || arrowMap['neutral'];
|
||||
const label = pct >= 70 ? 'Good' : pct <= 30 ? 'Evil' : 'Morally ambiguous';
|
||||
return '<div class="lib-char-align">'
|
||||
+ '<span class="lib-char-align-evil" title="Evil">●</span>'
|
||||
+ '<div class="lib-char-align-bar" title="' + label + ' (' + pct + '/100)">'
|
||||
+ '<div class="lib-char-align-dot" style="left:' + pct + '%"></div>'
|
||||
+ '</div>'
|
||||
+ '<span class="lib-char-align-good" title="Good">●</span>'
|
||||
+ '<span class="lib-char-align-arrow" style="color:' + a.color + '" title="' + a.tip + '">' + a.ch + '</span>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function _libStr(v) {
|
||||
if (v == null) return '';
|
||||
if (typeof v === 'string') return v;
|
||||
if (Array.isArray(v)) return v.filter(Boolean).join(', ');
|
||||
return JSON.stringify(v);
|
||||
}
|
||||
|
||||
function _charRelsHtml(rec, allChars) {
|
||||
if (!allChars || allChars.length < 2) return '';
|
||||
const relText = _libStr(rec.sheet?.relationships).toLowerCase();
|
||||
if (!relText) return '';
|
||||
const hits = allChars
|
||||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||||
.map(function (c) {
|
||||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
return { c: c, n: (relText.match(re) || []).length };
|
||||
})
|
||||
.filter(function (x) { return x.n > 0; })
|
||||
.sort(function (a, b) { return b.n - a.n; })
|
||||
.slice(0, 5);
|
||||
if (!hits.length) return '';
|
||||
return '<div class="lib-char-rels">'
|
||||
+ hits.map(function (x) {
|
||||
const h = _charHue(x.c.name);
|
||||
return '<span class="lib-char-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||||
}).join('')
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function _charCardHtml(rec, allChars) {
|
||||
const sh = rec.sheet || {};
|
||||
const hue = _charHue(rec.name);
|
||||
const hue2 = (hue + 40) % 360;
|
||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||||
const voiceLabel = voiceId ? escHtml(voiceId) : '<span style="opacity:.45">Keine Stimme</span>';
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
const tierBadge = tier === 'main' ? '<span class="lib-char-tier main">Haupt</span>'
|
||||
: tier === 'supporting' ? '<span class="lib-char-tier support">Neben</span>' : '';
|
||||
const gender = String(sh.gender || '').toLowerCase();
|
||||
const genderIcon = gender.startsWith('f') ? 'mdi-gender-female' : gender.startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||||
const snippet = _libStr(sh.mannerisms || sh.voice_pattern || sh.motivation || sh.backstory || '').slice(0, 140);
|
||||
|
||||
const avatarInner = rec.image
|
||||
? '<img src="' + rec.image + '" alt="' + escHtml(rec.name) + '" style="width:100%;height:100%;object-fit:cover;border-radius:50%">'
|
||||
: escHtml((rec.name || '?')[0].toUpperCase());
|
||||
|
||||
return '<div class="lib-char-card" data-char-id="' + escHtml(rec.id) + '">'
|
||||
+ '<div class="lib-char-card-banner" style="--ch1:hsl(' + hue + ',52%,35%);--ch2:hsl(' + hue2 + ',56%,26%)">'
|
||||
+ '<div class="lib-char-avatar" data-char-id="' + escHtml(rec.id) + '" title="Bild hochladen">' + avatarInner + '</div>'
|
||||
+ '<button class="lib-char-export" title="Als SillyTavern-Karte exportieren (.json)"><span class="mdi mdi-export-variant"></span></button>'
|
||||
+ '</div>'
|
||||
+ '<div class="lib-char-body">'
|
||||
+ '<div class="lib-char-name">' + tierBadge + escHtml(rec.name) + '<span class="mdi ' + genderIcon + '" style="font-size:11px;opacity:.5"></span></div>'
|
||||
+ (sh.archetype ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||||
+ (snippet ? '<div class="lib-char-snippet">' + escHtml(snippet) + '</div>' : '')
|
||||
+ _charAlignHtml(sh)
|
||||
+ _charRelsHtml(rec, allChars)
|
||||
+ '<div class="lib-char-divider"></div>'
|
||||
+ '<div class="lib-char-voice-row">'
|
||||
+ '<span class="lib-char-voice-label">' + voiceLabel + '</span>'
|
||||
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button>'
|
||||
+ '<button class="lib-char-auto-voice btn-sm">Auto</button>'
|
||||
+ '</div>'
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
// ── Character detail modal ────────────────────────────────────────────────────
|
||||
|
||||
function _lcdSourcesHtml(sources) {
|
||||
const list = Array.isArray(sources) ? sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
|
||||
if (!list.length) return '';
|
||||
return '<div class="lcd-sources">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text</div>'
|
||||
+ '<div class="lcd-source-list">'
|
||||
+ list.map(function (s) {
|
||||
const page = s.page != null ? 'Seite ' + s.page : '';
|
||||
const hint = _libStr(s.line_hint || s.hint || '');
|
||||
return '<div class="lcd-source-item">'
|
||||
+ (page || hint ? '<div class="lcd-source-page">' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
|
||||
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
|
||||
+ '</div>';
|
||||
}).join('')
|
||||
+ '</div></div>';
|
||||
}
|
||||
|
||||
function _lcdField(label, value, multiline) {
|
||||
const v = _libStr(value);
|
||||
if (!v) return '';
|
||||
return '<div class="lcd-field">'
|
||||
+ '<div class="lcd-field-label">' + label + '</div>'
|
||||
+ '<div class="lcd-field-value">' + (multiline ? escHtml(v) : escHtml(v)) + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function _lcdSection(icon, label, fields) {
|
||||
const body = fields.join('');
|
||||
if (!body) return '';
|
||||
return '<div class="lcd-section">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
|
||||
+ body
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function _lcdSectionFull(icon, label, fields) {
|
||||
const body = fields.join('');
|
||||
if (!body) return '';
|
||||
return '<div class="lcd-section-full">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi ' + icon + '"></span> ' + label + '</div>'
|
||||
+ body
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function _lcdFieldEdit(label, value, sheetKey) {
|
||||
const v = _libStr(value);
|
||||
return '<div class="lcd-field">'
|
||||
+ (label ? '<div class="lcd-field-label">' + escHtml(label) + '</div>' : '')
|
||||
+ '<div class="lcd-field-value lcd-field-editable" contenteditable="true" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(v) + '</div>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
function _jumpToReaderPage(pageNum) {
|
||||
// Always navigate to the reader section first
|
||||
if (typeof navTo === 'function') navTo('s-reader');
|
||||
setTimeout(function () {
|
||||
// Try page-div scroll (PDF reader)
|
||||
const pages = window.readerState?.pages;
|
||||
if (pages && pages.length >= pageNum) {
|
||||
const pg = pages[pageNum - 1];
|
||||
if (pg?.pageDiv) { pg.pageDiv.scrollIntoView({ behavior: 'smooth', block: 'start' }); return; }
|
||||
}
|
||||
// Try sentence-index jump — find first sentence on or after target page (0-indexed internally)
|
||||
const sentences = window.readerState?.sentences;
|
||||
if (sentences && sentences.length) {
|
||||
const target0 = pageNum - 1;
|
||||
const idx = sentences.findIndex(function (s) {
|
||||
return (s.words || []).some(function (w) { return (w.page ?? w.para ?? 0) >= target0; });
|
||||
});
|
||||
if (idx >= 0 && typeof readerJumpTo === 'function') { readerJumpTo(idx); return; }
|
||||
}
|
||||
toast('Öffne das Buch in „Vorlesen" und klicke nochmal auf die Quelle', 'info');
|
||||
}, 300);
|
||||
}
|
||||
|
||||
// ── Character detail PAGE (full-page with inline editing, replaces the modal) ─
|
||||
|
||||
async function _charDetailPage(rec, allChars) {
|
||||
const container = document.getElementById('lib-chars-list');
|
||||
if (!container) return;
|
||||
window._libDetailRec = rec;
|
||||
|
||||
const sh = rec.sheet || {};
|
||||
const hue = _charHue(rec.name);
|
||||
const hue2 = (hue + 40) % 360;
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
|
||||
const gender = _libStr(sh.gender);
|
||||
const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female'
|
||||
: gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||||
const score = sh.moral_alignment_score;
|
||||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||||
const arcMap = {
|
||||
'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
|
||||
'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
|
||||
'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
|
||||
'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
|
||||
'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' },
|
||||
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
|
||||
};
|
||||
const arcInfo = arcMap[sh.arc_direction || 'neutral'] || arcMap['neutral'];
|
||||
|
||||
const avatarHtml = rec.image
|
||||
? '<div class="lcd-avatar lcd-avatar-upload" title="Bild hochladen"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||||
: '<div class="lcd-avatar lcd-avatar-upload" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
|
||||
|
||||
const alignHtml = pct != null ? (
|
||||
'<div class="lcd-align-section">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||||
+ '<div class="lcd-align-bar-wrap">'
|
||||
+ '<span class="lcd-align-label">Böse</span>'
|
||||
+ '<input type="range" class="lcd-align-slider" min="0" max="100" value="' + pct + '">'
|
||||
+ '<span class="lcd-align-label">Gut</span>'
|
||||
+ '<span class="lcd-align-slider-val">' + pct + '/100</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label
|
||||
+ (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)')
|
||||
+ '</div>'
|
||||
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
|
||||
+ '</div>'
|
||||
) : '';
|
||||
|
||||
const relText = _libStr(sh.relationships).toLowerCase();
|
||||
const relHits = (allChars || [])
|
||||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||||
.map(function (c) {
|
||||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
return { c: c, n: (relText.match(re) || []).length };
|
||||
})
|
||||
.filter(function (x) { return x.n > 0; })
|
||||
.sort(function (a, b) { return b.n - a.n; })
|
||||
.slice(0, 8);
|
||||
const relDotsHtml = relHits.length ? (
|
||||
'<div class="lcd-rels-dots">'
|
||||
+ relHits.map(function (x) {
|
||||
const h = _charHue(x.c.name);
|
||||
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
) : '';
|
||||
|
||||
const sourcesList = Array.isArray(sh.sources) ? sh.sources.filter(function (s) { return s && (s.quote || s.page != null); }) : [];
|
||||
const sourcesHtml = sourcesList.length ? (
|
||||
'<div class="lcd-sources">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-text-box-search-outline"></span> Quellen im Text'
|
||||
+ '<span class="lcd-sources-hint"> · Klicken zum Springen</span></div>'
|
||||
+ '<div class="lcd-source-list">'
|
||||
+ sourcesList.map(function (s) {
|
||||
const page = s.page != null ? 'Seite ' + s.page : '';
|
||||
const hint = _libStr(s.line_hint || s.hint || '');
|
||||
return '<div class="lcd-source-item lcd-source-clickable" data-page="' + (s.page != null ? s.page : '') + '">'
|
||||
+ (page || hint ? '<div class="lcd-source-page"><span class="mdi mdi-book-open-page-variant-outline"></span> ' + escHtml([page, hint].filter(Boolean).join(' · ')) + '</div>' : '')
|
||||
+ (s.quote ? '<div class="lcd-source-quote">„' + escHtml(_libStr(s.quote)) + '"</div>' : '')
|
||||
+ '</div>';
|
||||
}).join('')
|
||||
+ '</div></div>'
|
||||
) : '';
|
||||
|
||||
const sidebarChars = (allChars || []).slice().sort(function (a, b) {
|
||||
return (b.sheet?.sources?.length || 0) - (a.sheet?.sources?.length || 0);
|
||||
});
|
||||
const sidebarHtml = sidebarChars.map(function (c) {
|
||||
const h = _charHue(c.name);
|
||||
const count = (c.sheet?.sources || []).length;
|
||||
return '<div class="lib-cpg-sidebar-item' + (c.id === rec.id ? ' is-active' : '') + '" data-char-id="' + escHtml(c.id) + '">'
|
||||
+ '<span class="lib-cpg-sidebar-dot" style="background:hsl(' + h + ',55%,38%)">' + escHtml((c.name || '?')[0].toUpperCase()) + '</span>'
|
||||
+ '<span class="lib-cpg-sidebar-name">' + escHtml(c.name) + '</span>'
|
||||
+ (count ? '<span class="lib-cpg-sidebar-count">' + count + '</span>' : '')
|
||||
+ '</div>';
|
||||
}).join('');
|
||||
|
||||
container.innerHTML = '';
|
||||
const pg = document.createElement('div');
|
||||
pg.className = 'lib-char-page';
|
||||
pg.innerHTML =
|
||||
'<div class="lib-cpg-main">'
|
||||
+ '<button class="lib-cpg-back"><span class="mdi mdi-arrow-left"></span> Alle Charaktere</button>'
|
||||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + hue2 + ',58%,25%)">'
|
||||
+ avatarHtml
|
||||
+ '<div class="lcd-header-body">'
|
||||
+ '<div class="lcd-name" contenteditable="true" data-rec-key="name" spellcheck="false">' + escHtml(rec.name) + '</div>'
|
||||
+ '<div class="lcd-aliases" contenteditable="true" data-sheet-key="aliases" spellcheck="false">' + escHtml(_libStr(sh.aliases)) + '</div>'
|
||||
+ '<div class="lcd-archetype" contenteditable="true" data-sheet-key="archetype" spellcheck="false">' + escHtml(_libStr(sh.archetype)) + '</div>'
|
||||
+ '<div class="lcd-tier-gender">'
|
||||
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
|
||||
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lcd-body">'
|
||||
+ '<div class="lcd-voice-top">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||||
+ '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
||||
+ '</div>'
|
||||
+ alignHtml
|
||||
+ '<div class="lcd-sections">'
|
||||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||||
_lcdFieldEdit('Körperlich', sh.physical, 'physical'),
|
||||
_lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'),
|
||||
])
|
||||
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
|
||||
_lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms'),
|
||||
_lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern'),
|
||||
])
|
||||
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
|
||||
_lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory'),
|
||||
_lcdFieldEdit('Motivation', sh.motivation, 'motivation'),
|
||||
_lcdFieldEdit('Ängste', sh.fears, 'fears'),
|
||||
])
|
||||
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
|
||||
_lcdFieldEdit('Fertigkeiten', sh.skills, 'skills'),
|
||||
_lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities'),
|
||||
_lcdFieldEdit('Stärkstes Attribut', sh.attribute_high, 'attribute_high'),
|
||||
_lcdFieldEdit('Schwächstes Attribut', sh.attribute_low, 'attribute_low'),
|
||||
])
|
||||
+ _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
|
||||
_lcdFieldEdit('', sh.relationships, 'relationships'),
|
||||
relDotsHtml,
|
||||
])
|
||||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||||
_lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style'),
|
||||
_lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition'),
|
||||
])
|
||||
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
|
||||
_lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
|
||||
_lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
|
||||
])
|
||||
+ '</div>'
|
||||
+ sourcesHtml
|
||||
+ (rec.analysis ? '<div class="lcd-analysis" contenteditable="true" data-rec-key="analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lib-cpg-sidebar">'
|
||||
+ '<div class="lib-cpg-sidebar-title">Charaktere · ' + escHtml(rec.book || '') + '</div>'
|
||||
+ sidebarHtml
|
||||
+ '</div>';
|
||||
|
||||
container.appendChild(pg);
|
||||
|
||||
pg.querySelector('.lib-cpg-back').addEventListener('click', function () { libraryRenderCharacters(); });
|
||||
|
||||
pg.querySelector('.lcd-avatar-upload').addEventListener('click', function () {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*';
|
||||
inp.onchange = async function () {
|
||||
const file = inp.files[0]; if (!file) return;
|
||||
const fr = new FileReader();
|
||||
fr.onload = async function (ev) {
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||||
toast('Profilbild gespeichert', 'success');
|
||||
rec.image = ev.target.result;
|
||||
const av = pg.querySelector('.lcd-avatar-upload');
|
||||
if (av) av.innerHTML = '<img src="' + ev.target.result + '" alt="' + escHtml(rec.name) + '">';
|
||||
};
|
||||
fr.readAsDataURL(file);
|
||||
};
|
||||
inp.click();
|
||||
});
|
||||
|
||||
pg.querySelectorAll('.lib-cpg-sidebar-item').forEach(function (item) {
|
||||
item.addEventListener('click', async function () {
|
||||
const target = (allChars || []).find(function (c) { return c.id === item.dataset.charId; });
|
||||
if (target) _charDetailPage(target, allChars);
|
||||
});
|
||||
});
|
||||
|
||||
pg.querySelectorAll('.lcd-source-clickable').forEach(function (item) {
|
||||
item.addEventListener('click', function () {
|
||||
const n = parseInt(item.dataset.page, 10);
|
||||
if (!isNaN(n)) _jumpToReaderPage(n);
|
||||
});
|
||||
});
|
||||
|
||||
pg.querySelector('.lcd-pick-voice')?.addEventListener('click', async function () {
|
||||
_openVoicePicker(pg.querySelector('.lcd-voice-top'), rec, async function () {
|
||||
const all = await clGetAll().catch(() => allChars);
|
||||
const up = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||||
_charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }));
|
||||
});
|
||||
});
|
||||
pg.querySelector('.lcd-auto-voice')?.addEventListener('click', async function () {
|
||||
await _autoAssignVoice(rec);
|
||||
const all = await clGetAll().catch(() => allChars);
|
||||
const up = all.find(function (r) { return r.id === rec.id; }) || rec;
|
||||
_charDetailPage(up, all.filter(function (r) { return r.book === rec.book; }));
|
||||
});
|
||||
pg.querySelector('.lcd-online-voice')?.addEventListener('click', function () { _charSearchOnline(rec); });
|
||||
pg.querySelector('.lcd-gen-voice')?.addEventListener('click', function () { _charDesignVoice(rec); });
|
||||
|
||||
const slider = pg.querySelector('.lcd-align-slider');
|
||||
const sliderVal = pg.querySelector('.lcd-align-slider-val');
|
||||
const arcEl = pg.querySelector('.lcd-align-arc');
|
||||
if (slider) {
|
||||
slider.addEventListener('input', async function () {
|
||||
const val = parseInt(slider.value, 10);
|
||||
if (sliderVal) sliderVal.textContent = val + '/100';
|
||||
if (arcEl) arcEl.textContent = arcInfo.ch + ' ' + arcInfo.label
|
||||
+ (val >= 70 ? ' · Rechtschaffen (' + val + '/100)' : val <= 30 ? ' · Böse (' + val + '/100)' : ' · Moralisch ambivalent (' + val + '/100)');
|
||||
arcEl && (arcEl.style.color = arcInfo.color);
|
||||
rec.sheet.moral_alignment_score = val;
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
});
|
||||
}
|
||||
|
||||
let _saveTimer = null;
|
||||
function _schedSave(key, value, isRecKey) {
|
||||
clearTimeout(_saveTimer);
|
||||
_saveTimer = setTimeout(async function () {
|
||||
if (isRecKey) { rec[key] = value; }
|
||||
else { if (!rec.sheet) rec.sheet = {}; rec.sheet[key] = value; }
|
||||
rec.updated = new Date();
|
||||
if (typeof clPut === 'function') await clPut(rec);
|
||||
}, 900);
|
||||
}
|
||||
pg.querySelectorAll('[contenteditable][data-sheet-key]').forEach(function (el) {
|
||||
el.addEventListener('input', function () { _schedSave(el.dataset.sheetKey, el.textContent.trim(), false); });
|
||||
});
|
||||
pg.querySelectorAll('[contenteditable][data-rec-key]').forEach(function (el) {
|
||||
el.addEventListener('input', function () { _schedSave(el.dataset.recKey, el.textContent.trim(), true); });
|
||||
});
|
||||
}
|
||||
|
||||
window._charDetailPage = _charDetailPage;
|
||||
|
||||
function _charDetailModal(rec, allChars) {
|
||||
const sh = rec.sheet || {};
|
||||
const cov = libBookCover(rec.name);
|
||||
const hue = _charHue(rec.name);
|
||||
const tier = String(sh.tier || '').toLowerCase();
|
||||
const tierLabel = tier === 'main' ? 'Hauptcharakter' : tier === 'supporting' ? 'Nebencharakter' : tier === 'minor' ? 'Nebenfigur' : '';
|
||||
const gender = _libStr(sh.gender);
|
||||
const genderIcon = gender.toLowerCase().startsWith('f') ? 'mdi-gender-female' : gender.toLowerCase().startsWith('m') ? 'mdi-gender-male' : 'mdi-gender-non-binary';
|
||||
const score = sh.moral_alignment_score;
|
||||
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
|
||||
const arc = sh.arc_direction || 'neutral';
|
||||
const arcMap = {
|
||||
'good-to-bad': { ch: '↘', label: 'Entwicklung zum Bösen', color: '#ff7043' },
|
||||
'bad-to-good': { ch: '↗', label: 'Wandel zum Guten', color: '#66bb6a' },
|
||||
'complex': { ch: '↕', label: 'Komplex / unvorhersehbar', color: '#ab47bc' },
|
||||
'stable-good': { ch: '→', label: 'Stabil gut', color: '#66bb6a' },
|
||||
'stable-bad': { ch: '→', label: 'Stabil böse', color: '#888' },
|
||||
'neutral': { ch: '→', label: 'Neutral / stabil', color: '#aaa' },
|
||||
};
|
||||
const arcInfo = arcMap[arc] || arcMap['neutral'];
|
||||
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
|
||||
|
||||
const avatarHtml = rec.image
|
||||
? '<div class="lcd-avatar"><img src="' + rec.image + '" alt="' + escHtml(rec.name) + '"></div>'
|
||||
: '<div class="lcd-avatar" style="background:hsl(' + hue + ',55%,38%)">' + escHtml((rec.name || '?')[0].toUpperCase()) + '</div>';
|
||||
|
||||
const alignHtml = pct != null ? (
|
||||
'<div class="lcd-align-section">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-scale-balance"></span> Moralische Gesinnung</div>'
|
||||
+ '<div class="lcd-align-bar-wrap">'
|
||||
+ '<span class="lcd-align-label">Böse</span>'
|
||||
+ '<div class="lcd-align-bar"><div class="lcd-align-dot" style="left:' + pct + '%"></div></div>'
|
||||
+ '<span class="lcd-align-label">Gut</span>'
|
||||
+ '</div>'
|
||||
+ '<div class="lcd-align-arc" style="color:' + arcInfo.color + '">' + arcInfo.ch + ' ' + arcInfo.label + (pct >= 70 ? ' · Rechtschaffen (' + pct + '/100)' : pct <= 30 ? ' · Böse (' + pct + '/100)' : ' · Moralisch ambivalent (' + pct + '/100)') + '</div>'
|
||||
+ (_libStr(sh.arc_note) ? '<div class="lcd-arc-note">' + escHtml(_libStr(sh.arc_note)) + '</div>' : '')
|
||||
+ (_libStr(sh.alignment) ? '<div class="lcd-arc-note" style="margin-top:6px">' + escHtml(_libStr(sh.alignment)) + '</div>' : '')
|
||||
+ '</div>'
|
||||
) : '';
|
||||
|
||||
// Relationship dots (same logic as card)
|
||||
const relText = _libStr(sh.relationships).toLowerCase();
|
||||
const relHits = (allChars || [])
|
||||
.filter(function (c) { return c.id !== rec.id && (c.name || '').length > 1; })
|
||||
.map(function (c) {
|
||||
const re = new RegExp(c.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
||||
return { c: c, n: (relText.match(re) || []).length };
|
||||
})
|
||||
.filter(function (x) { return x.n > 0; })
|
||||
.sort(function (a, b) { return b.n - a.n; })
|
||||
.slice(0, 8);
|
||||
|
||||
const relDotsHtml = relHits.length ? (
|
||||
'<div class="lcd-rels-dots">'
|
||||
+ relHits.map(function (x) {
|
||||
const h = _charHue(x.c.name);
|
||||
return '<span class="lcd-rel-dot" style="background:hsl(' + h + ',55%,38%)" title="' + escHtml(x.c.name) + ' (' + x.n + '×)">'
|
||||
+ escHtml((x.c.name || '?')[0].toUpperCase()) + '</span>';
|
||||
}).join('')
|
||||
+ '</div>'
|
||||
) : '';
|
||||
|
||||
const ov = document.createElement('div');
|
||||
ov.className = 'lib-char-detail-ov';
|
||||
ov.innerHTML = '<div class="lib-char-detail-box">'
|
||||
+ '<div class="lcd-header" style="--lc1:hsl(' + hue + ',55%,35%);--lc2:hsl(' + ((hue+40)%360) + ',58%,25%)">'
|
||||
+ avatarHtml
|
||||
+ '<div class="lcd-header-body">'
|
||||
+ '<div class="lcd-name">' + escHtml(rec.name) + '</div>'
|
||||
+ (_libStr(sh.aliases) ? '<div class="lcd-aliases">auch bekannt als ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
|
||||
+ (_libStr(sh.archetype) ? '<div class="lcd-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
|
||||
+ '<div class="lcd-tier-gender">'
|
||||
+ (tierLabel ? '<span class="lcd-tier">' + tierLabel + '</span>' : '')
|
||||
+ (gender ? '<span class="lcd-gender"><span class="mdi ' + genderIcon + '"></span> ' + escHtml(gender) + '</span>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lcd-header-btns">'
|
||||
+ '<button class="lcd-edit-btn"><span class="mdi mdi-pencil-outline"></span> Bearbeiten</button>'
|
||||
+ '<button class="lcd-close-btn"><span class="mdi mdi-close"></span></button>'
|
||||
+ '</div>'
|
||||
+ '</div>'
|
||||
+ '<div class="lcd-body">'
|
||||
+ '<div class="lcd-voice-top">'
|
||||
+ '<div class="lcd-section-label"><span class="mdi mdi-account-voice"></span> Stimme</div>'
|
||||
+ '<span class="lcd-voice-label">' + (voiceId ? escHtml(voiceId) : '<span style="opacity:.5">Noch keine Stimme zugewiesen</span>') + '</span>'
|
||||
+ '<button class="lcd-pick-voice">Auswählen</button>'
|
||||
+ '<button class="lcd-auto-voice">Automatisch</button>'
|
||||
+ '<button class="lcd-online-voice">Online suchen</button>'
|
||||
+ '<button class="lcd-gen-voice">Generieren</button>'
|
||||
+ '</div>'
|
||||
+ alignHtml
|
||||
+ '<div class="lcd-sections">'
|
||||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||||
_lcdField('Körperlich', sh.physical, true),
|
||||
_lcdField('Kleidung & Aussehen', sh.clothing, true),
|
||||
])
|
||||
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
|
||||
_lcdField('Eigenheiten & Verhalten', sh.mannerisms, true),
|
||||
_lcdField('Stimme & Sprache', sh.voice_pattern, true),
|
||||
])
|
||||
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
|
||||
_lcdField('Hintergrund & Herkunft', sh.backstory, true),
|
||||
_lcdField('Motivation', sh.motivation, true),
|
||||
_lcdField('Ängste', sh.fears, true),
|
||||
])
|
||||
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
|
||||
_lcdField('Fertigkeiten', sh.skills, true),
|
||||
_lcdField('Besondere Fähigkeiten', sh.capabilities, true),
|
||||
_lcdField('Stärkstes Attribut', sh.attribute_high, false),
|
||||
_lcdField('Schwächstes Attribut', sh.attribute_low, false),
|
||||
])
|
||||
+ _lcdSectionFull('mdi-account-group-outline', 'Beziehungen', [
|
||||
_lcdField('', sh.relationships, true),
|
||||
relDotsHtml,
|
||||
])
|
||||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||||
_lcdField('Konfliktstil', sh.conflict_style, true),
|
||||
_lcdField('Siegbedingung', sh.win_condition, true),
|
||||
])
|
||||
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
|
||||
_lcdField('Dunkles Geheimnis / fataler Fehler', sh.secret, true),
|
||||
_lcdField('Charakterentwicklung', sh.arc_note, true),
|
||||
])
|
||||
+ '</div>'
|
||||
+ _lcdSourcesHtml(sh.sources)
|
||||
+ (rec.analysis ? '<div class="lcd-analysis">' + escHtml(String(rec.analysis)) + '</div>' : '')
|
||||
+ '</div>'
|
||||
+ '</div>';
|
||||
|
||||
document.body.appendChild(ov);
|
||||
|
||||
const close = function () { ov.remove(); };
|
||||
ov.querySelector('.lcd-close-btn').addEventListener('click', close);
|
||||
ov.addEventListener('click', function (e) { if (e.target === ov) close(); });
|
||||
|
||||
ov.querySelector('.lcd-edit-btn').addEventListener('click', function () {
|
||||
close();
|
||||
if (typeof clEdit === 'function') clEdit(rec.id);
|
||||
});
|
||||
|
||||
// Voice buttons inside detail modal
|
||||
const box = ov.querySelector('.lib-char-detail-box');
|
||||
ov.querySelector('.lcd-pick-voice').addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_openVoicePicker(box, rec, function () { close(); libraryRenderCharacters(); });
|
||||
});
|
||||
ov.querySelector('.lcd-auto-voice').addEventListener('click', async function (e) {
|
||||
e.stopPropagation();
|
||||
await _autoAssignVoice(rec);
|
||||
close();
|
||||
libraryRenderCharacters();
|
||||
});
|
||||
ov.querySelector('.lcd-online-voice').addEventListener('click', function (e) {
|
||||
e.stopPropagation(); _charSearchOnline(rec);
|
||||
});
|
||||
ov.querySelector('.lcd-gen-voice').addEventListener('click', function (e) {
|
||||
e.stopPropagation(); _charDesignVoice(rec);
|
||||
});
|
||||
|
||||
// Avatar click to upload image
|
||||
ov.querySelector('.lcd-avatar').addEventListener('click', function () {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = 'image/*';
|
||||
inp.onchange = async function () {
|
||||
const file = inp.files[0]; if (!file) return;
|
||||
const fr = new FileReader();
|
||||
fr.onload = async function (ev) {
|
||||
if (typeof clSetImage === 'function') await clSetImage(rec.id, ev.target.result);
|
||||
toast('Profile picture saved', 'success');
|
||||
close();
|
||||
libraryRenderCharacters();
|
||||
};
|
||||
fr.readAsDataURL(file);
|
||||
};
|
||||
inp.click();
|
||||
});
|
||||
}
|
||||
|
||||
window._charDetailModal = _charDetailModal;
|
||||
|
||||
function _openVoicePicker(cardEl, rec, onDone) {
|
||||
// Remove any existing picker
|
||||
document.querySelectorAll('.lib-voice-picker-popup').forEach(function (p) { p.remove(); });
|
||||
|
||||
const voices = window._voices || [];
|
||||
const gender = String(rec.sheet?.gender || '').toLowerCase();
|
||||
const genderMatch = gender.startsWith('f') ? 'f' : gender.startsWith('m') ? 'm' : '';
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'lib-voice-picker-popup';
|
||||
popup.innerHTML = '<div class="lib-vp-search"><input type="search" placeholder="Search voices…" class="lib-vp-input" autocomplete="off"></div>'
|
||||
+ '<div class="lib-vp-list"></div>';
|
||||
|
||||
function renderList(filter) {
|
||||
let list = voices.filter(function (v) { return v.enabled !== false; });
|
||||
if (filter) {
|
||||
const f = filter.toLowerCase();
|
||||
list = list.filter(function (v) { return (v.id || '').toLowerCase().includes(f) || (v.name || '').toLowerCase().includes(f); });
|
||||
} else if (genderMatch) {
|
||||
list = list.filter(function (v) {
|
||||
const vg = String(v.gender || '').toLowerCase();
|
||||
return vg.startsWith(genderMatch) || !vg;
|
||||
}).concat(list.filter(function (v) {
|
||||
const vg = String(v.gender || '').toLowerCase();
|
||||
return vg && !vg.startsWith(genderMatch);
|
||||
}));
|
||||
}
|
||||
const ul = popup.querySelector('.lib-vp-list');
|
||||
ul.innerHTML = list.slice(0, 60).map(function (v) {
|
||||
const sel = v.id === rec.voice;
|
||||
return '<div class="lib-vp-item' + (sel ? ' selected' : '') + '" data-vid="' + escHtml(v.id) + '">'
|
||||
+ escHtml(v.id || v.name || '') + (v.gender ? ' <span style="opacity:.5;font-size:10px">· ' + escHtml(v.gender) + '</span>' : '')
|
||||
+ '</div>';
|
||||
}).join('') + (list.length === 0 ? '<div style="padding:12px;opacity:.5;font-size:12px">No voices found</div>' : '');
|
||||
|
||||
ul.querySelectorAll('.lib-vp-item').forEach(function (item) {
|
||||
item.addEventListener('click', async function () {
|
||||
const vid = item.dataset.vid;
|
||||
await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: vid }));
|
||||
popup.remove();
|
||||
onDone();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
renderList('');
|
||||
popup.querySelector('.lib-vp-input').addEventListener('input', function (e) { renderList(e.target.value); });
|
||||
|
||||
// Position near the card
|
||||
cardEl.style.position = 'relative';
|
||||
cardEl.appendChild(popup);
|
||||
|
||||
// Close on outside click
|
||||
setTimeout(function () {
|
||||
function close(e) { if (!popup.contains(e.target)) { popup.remove(); document.removeEventListener('click', close); } }
|
||||
document.addEventListener('click', close);
|
||||
}, 0);
|
||||
|
||||
popup.querySelector('.lib-vp-input').focus();
|
||||
}
|
||||
|
||||
async function _autoAssignVoice(rec) {
|
||||
const voices = (window._voices || []).filter(function (v) { return v.enabled !== false; });
|
||||
if (!voices.length) { toast('Voice library not loaded', 'error'); return; }
|
||||
|
||||
const gender = String(rec.sheet?.gender || '').toLowerCase().trim();
|
||||
// Handle both English (female/male) and German (weiblich/männlich) gender terms
|
||||
const isFemale = gender.startsWith('f') || gender.startsWith('w'); // female, weiblich
|
||||
const isMale = !isFemale && gender.startsWith('m'); // male, männlich
|
||||
const gMatch = isFemale ? 'f' : isMale ? 'm' : '';
|
||||
let pool = gMatch ? voices.filter(function (v) {
|
||||
const vg = String(v.gender || '').toLowerCase();
|
||||
return gMatch === 'f' ? (vg.startsWith('f') || vg.startsWith('w')) : vg.startsWith('m');
|
||||
}) : voices;
|
||||
if (!pool.length) pool = voices;
|
||||
|
||||
// Prefer unassigned voices (not already used by another character in same book)
|
||||
const usedInBook = new Set();
|
||||
try {
|
||||
const bookChars = await clGetAllByTagOrBook(rec.book);
|
||||
bookChars.forEach(function (r) { if (r.voice && r.id !== rec.id) usedInBook.add(r.voice); });
|
||||
} catch (_) {}
|
||||
const fresh = pool.filter(function (v) { return !usedInBook.has(v.id); });
|
||||
const candidate = (fresh.length ? fresh : pool).sort(function (a, b) { return (b.rating || 0) - (a.rating || 0); })[0];
|
||||
|
||||
if (!candidate) { toast('No matching voice found', 'error'); return; }
|
||||
await clUpsert(rec.book, Object.assign({}, rec.sheet, { name: rec.name, voice: candidate.id }));
|
||||
toast('Assigned ' + candidate.id + ' → ' + rec.name, 'success');
|
||||
}
|
||||
|
||||
// Detect the production language from a character's own (book-language) text.
|
||||
function _charLang(rec) {
|
||||
const sh = rec.sheet || {};
|
||||
const text = [sh.backstory, sh.voice_pattern, sh.mannerisms, sh.relationships, sh.motivation, sh.archetype]
|
||||
.filter(Boolean).join(' ');
|
||||
return (typeof detectLang === 'function') ? detectLang(text) : '';
|
||||
}
|
||||
|
||||
// Build a natural-language voice-design prompt from a character sheet.
|
||||
function _buildVoicePrompt(rec) {
|
||||
const sh = rec.sheet || {};
|
||||
const g = String(sh.gender || '').toLowerCase();
|
||||
const genderWord = g.startsWith('f') ? 'female' : g.startsWith('m') ? 'male' : '';
|
||||
const bits = [];
|
||||
bits.push('A ' + (genderWord ? genderWord + ' ' : '') + 'voice'
|
||||
+ (sh.archetype ? ' for ' + sh.archetype.toLowerCase() : '') + '.');
|
||||
if (sh.voice_pattern) bits.push(sh.voice_pattern);
|
||||
if (sh.mannerisms) bits.push('Mannerisms: ' + sh.mannerisms);
|
||||
if (sh.physical) bits.push(sh.physical);
|
||||
if (sh.alignment) bits.push('Disposition: ' + sh.alignment);
|
||||
return bits.join(' ').slice(0, 600);
|
||||
}
|
||||
|
||||
function _selectLoose(sel, val) {
|
||||
if (!sel || !val) return;
|
||||
const v = String(val).toLowerCase();
|
||||
const opt = [...sel.options].find(function (o) {
|
||||
const ov = o.value.toLowerCase(), ot = o.textContent.toLowerCase();
|
||||
return ov === v || ot === v || ov.startsWith(v) || ot.startsWith(v) || v.startsWith(ov);
|
||||
});
|
||||
if (opt) { sel.value = opt.value; sel.dispatchEvent(new Event('change')); }
|
||||
}
|
||||
|
||||
// Search a matching voice online — opens Get a Voice Online on the Fish.audio
|
||||
// tab, pre-filled with the character name + detected language.
|
||||
function _charSearchOnline(rec) {
|
||||
if (typeof navTo === 'function') navTo('s-studio');
|
||||
const lang = _charLang(rec);
|
||||
setTimeout(function () {
|
||||
const fishTab = document.querySelector('#gvo-tabs .gvo-tab[data-src="fish"]');
|
||||
if (fishTab) fishTab.click();
|
||||
setTimeout(function () {
|
||||
const langSel = document.getElementById('fa-lang');
|
||||
if (langSel) _selectLoose(langSel, lang);
|
||||
const search = document.getElementById('fa-search');
|
||||
if (search) {
|
||||
search.value = rec.name;
|
||||
search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
||||
}
|
||||
}, 120);
|
||||
}, 120);
|
||||
toast('Searching online voices for ' + rec.name + (lang ? ' (' + lang + ')' : ''), 'info');
|
||||
}
|
||||
|
||||
// Generate a new voice — opens Design a Voice, pre-filled with gender, the
|
||||
// detected language, a description built from the sheet, and the character name.
|
||||
function _charDesignVoice(rec) {
|
||||
if (typeof navTo === 'function') navTo('s-design');
|
||||
const sh = rec.sheet || {};
|
||||
const lang = _charLang(rec);
|
||||
setTimeout(function () {
|
||||
_selectLoose(document.getElementById('design-gender'), sh.gender);
|
||||
_selectLoose(document.getElementById('design-language'), lang);
|
||||
const instruct = document.getElementById('design-instruct');
|
||||
if (instruct) instruct.value = _buildVoicePrompt(rec);
|
||||
const nm = document.getElementById('design-preset-name');
|
||||
if (nm) nm.value = rec.name;
|
||||
}, 140);
|
||||
toast('Voice design prepared for ' + rec.name + (lang ? ' · ' + lang : ''), 'info');
|
||||
}
|
||||
|
||||
window._charSearchOnline = _charSearchOnline;
|
||||
window._charDesignVoice = _charDesignVoice;
|
||||
window.libraryRenderCharacters = libraryRenderCharacters;
|
||||
window.libraryRenderBooks = libraryRenderBooks;
|
||||
window.libraryRenderPlays = libraryRenderPlays;
|
||||
window.productionOpenInRehearser = productionOpenInRehearser;
|
||||
|
||||
@ -1018,6 +1018,7 @@ function readerStopPlayback() {
|
||||
// a sentence left mid-read reverts to its cached/uncached colour
|
||||
readerClearReading();
|
||||
readerUpdatePlayBtn();
|
||||
if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', false);
|
||||
}
|
||||
|
||||
function readerPlay() {
|
||||
@ -1026,6 +1027,7 @@ function readerPlay() {
|
||||
readerCtx();
|
||||
readerState.playing = true;
|
||||
readerUpdatePlayBtn();
|
||||
if (typeof window.setNavBusy === 'function') window.setNavBusy('s-reader', true);
|
||||
readerPlayCurrent();
|
||||
}
|
||||
|
||||
@ -1188,7 +1190,23 @@ async function readerOpenLibraryDoc(id) {
|
||||
try { const sr = await fetch(`${READER_API}/${id}/source`); if (sr.ok) sourceBlob = await sr.blob(); } catch (_) {}
|
||||
try {
|
||||
if (rec.kind === 'pdf') {
|
||||
if (!sourceBlob) { toast('Saved PDF data missing', 'error'); return; }
|
||||
if (!sourceBlob) {
|
||||
// PDF upload failed at save time — let user re-supply the file
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file'; inp.accept = '.pdf';
|
||||
inp.onchange = async () => {
|
||||
const file = inp.files[0]; if (!file) return;
|
||||
const put = await fetch(`${READER_API}/${id}/source?ext=pdf`, { method: 'PUT', body: file });
|
||||
if (!put.ok) { toast('Re-upload failed', 'error'); return; }
|
||||
toast('PDF restored — opening…', 'success');
|
||||
readerState.savedId = id; readerState.sourceUploaded = true;
|
||||
readerState.fileBlob = file;
|
||||
await readerLoadPdf(file);
|
||||
};
|
||||
inp.click();
|
||||
toast('PDF source missing — please re-select the original file', 'error');
|
||||
return;
|
||||
}
|
||||
readerState.fileBlob = sourceBlob;
|
||||
toast('Opening book…', 'success');
|
||||
await readerLoadPdf(sourceBlob);
|
||||
@ -1494,6 +1512,7 @@ window.showReaderView = function (view) {
|
||||
if (main) main.hidden = (view !== 'main');
|
||||
if (cast) {
|
||||
cast.hidden = (view !== 'cast');
|
||||
if (view !== 'cast') cast.style.display = '';
|
||||
if (view === 'cast' && !cast.innerHTML.trim()) {
|
||||
cast.innerHTML = '<div style="padding:60px 20px; text-align:center; color:var(--subtext);"><span class="mdi mdi-drama-masks" style="font-size:48px; opacity:0.5; margin-bottom:16px; display:block;"></span><h3 style="margin:0 0 8px 0; color:var(--text);">No active casting session</h3><p style="margin:0;">Open a document in the Reader tab and click "Cast as audiobook" to start extracting characters.</p><br><button class="btn-primary" onclick="navReaderView(\'main\')">Go to Reader</button></div>';
|
||||
cast.className = 'ab-castpanel-inline card';
|
||||
|
||||
@ -90,54 +90,94 @@ const rehState = {
|
||||
};
|
||||
window.rehState = rehState;
|
||||
|
||||
// ── Library — IndexedDB ────────────────────────────────────────────────────
|
||||
|
||||
const REH_DB_NAME = 'reh-library';
|
||||
const REH_STORE = 'rehearsals';
|
||||
|
||||
function rehDbOpen() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(REH_DB_NAME, 1);
|
||||
req.onupgradeneeded = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(REH_STORE)) {
|
||||
const store = db.createObjectStore(REH_STORE, { keyPath: 'id', autoIncrement: true });
|
||||
store.createIndex('updated', 'updated', { unique: false });
|
||||
}
|
||||
};
|
||||
req.onsuccess = e => resolve(e.target.result);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function rehDbOp(mode, fn) {
|
||||
const db = await rehDbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(REH_STORE, mode);
|
||||
const store = tx.objectStore(REH_STORE);
|
||||
const req = fn(store);
|
||||
req.onsuccess = e => resolve(e.target.result);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function rehDbAdd(record) { return rehDbOp('readwrite', s => s.add(record)); }
|
||||
async function rehDbPut(record) { return rehDbOp('readwrite', s => s.put(record)); }
|
||||
async function rehDbDelete(id) { return rehDbOp('readwrite', s => s.delete(id)); }
|
||||
async function rehDbGetById(id) { return rehDbOp('readonly', s => s.get(id)); }
|
||||
window.rehDbGetById = rehDbGetById;
|
||||
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
|
||||
// ── Library — SQLite via /api/rehearsals ──────────────────────────────────
|
||||
|
||||
async function rehDbGetAll() {
|
||||
const db = await rehDbOpen();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(REH_STORE, 'readonly');
|
||||
const req = tx.objectStore(REH_STORE).getAll();
|
||||
req.onsuccess = e => resolve(e.target.result || []);
|
||||
req.onerror = e => reject(e.target.error);
|
||||
const r = await fetch('/api/rehearsals');
|
||||
if (!r.ok) throw new Error('rehDbGetAll failed: ' + r.status);
|
||||
const d = await r.json();
|
||||
return d.rehearsals || [];
|
||||
}
|
||||
|
||||
async function rehDbGetById(id) {
|
||||
const r = await fetch('/api/rehearsals/' + id);
|
||||
if (r.status === 404) return undefined;
|
||||
if (!r.ok) throw new Error('rehDbGetById failed: ' + r.status);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
async function rehDbAdd(record) {
|
||||
const r = await fetch('/api/rehearsals', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_rehSanitize(record)),
|
||||
});
|
||||
if (!r.ok) throw new Error('rehDbAdd failed: ' + r.status);
|
||||
const d = await r.json();
|
||||
return d.id;
|
||||
}
|
||||
|
||||
async function rehDbPut(record) {
|
||||
if (!record.id) { return rehDbAdd(record); }
|
||||
const r = await fetch('/api/rehearsals/' + record.id, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(_rehSanitize(record)),
|
||||
});
|
||||
if (!r.ok) throw new Error('rehDbPut failed: ' + r.status);
|
||||
}
|
||||
|
||||
async function rehDbDelete(id) {
|
||||
const r = await fetch('/api/rehearsals/' + id, { method: 'DELETE' });
|
||||
if (!r.ok) throw new Error('rehDbDelete failed: ' + r.status);
|
||||
}
|
||||
|
||||
// Strip binary clip blobs before sending — blobs can't be JSON-serialised and
|
||||
// are session-only anyway (they live in rehState.clips, not the library record).
|
||||
function _rehSanitize(rec) {
|
||||
const out = { ...rec };
|
||||
if (out.clips) out.clips = (out.clips || []).map(c => ({ lineIndex: c.lineIndex, speaker: c.speaker, type: c.type }));
|
||||
return out;
|
||||
}
|
||||
|
||||
// One-time IndexedDB → SQLite migration. Runs silently on page load.
|
||||
(async function _rehMigrateIfNeeded() {
|
||||
try {
|
||||
const serverRecs = await rehDbGetAll();
|
||||
if (serverRecs.length > 0) return;
|
||||
const idbRecs = await _rehIdbGetAll().catch(() => []);
|
||||
if (!idbRecs.length) return;
|
||||
const r = await fetch('/api/rehearsals/migrate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(idbRecs.map(_rehSanitize)),
|
||||
});
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
console.log(`[rehearser] migrated ${d.imported} records from IndexedDB → SQLite`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[rehearser] migration skipped:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
function _rehIdbGetAll() {
|
||||
return new Promise((resolve) => {
|
||||
const req = indexedDB.open('reh-library', 1);
|
||||
req.onerror = () => resolve([]);
|
||||
req.onsuccess = e => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains('rehearsals')) { db.close(); resolve([]); return; }
|
||||
const all = db.transaction('rehearsals', 'readonly').objectStore('rehearsals').getAll();
|
||||
all.onsuccess = ev => { db.close(); resolve(ev.target.result || []); };
|
||||
all.onerror = () => { db.close(); resolve([]); };
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
window.rehDbGetById = rehDbGetById;
|
||||
window.rehLoadRecord = loadRecord; // expose so audiobook.js can open a record directly
|
||||
|
||||
// ── Serialization ─────────────────────────────────────────────────────────
|
||||
|
||||
async function clipsToJson(clips) {
|
||||
|
||||
@ -240,9 +240,12 @@ async function loadRoutingTab() {
|
||||
$('routing-status').textContent = 'Loading routing…';
|
||||
$('routing-list').innerHTML = loadingMarkup('Loading routing', 'Loading active voices and routing rules for the proxy.', 5);
|
||||
setBusyButton('routing-refresh-btn', true);
|
||||
// Load voices + sounds in parallel (non-fatal — routing still works without them)
|
||||
await Promise.allSettled([
|
||||
_voices.length ? Promise.resolve() : loadVoiceLibrary().catch(() => {}),
|
||||
loadRouteSounds(),
|
||||
]);
|
||||
try {
|
||||
if (!_voices.length) await loadVoiceLibrary();
|
||||
await loadRouteSounds();
|
||||
const r = await fetch('/api/tts-routes');
|
||||
if (!r.ok) { const e = await r.json().catch(() => ({})); throw new Error(e.detail || r.statusText); }
|
||||
const d = await r.json();
|
||||
@ -252,7 +255,7 @@ async function loadRoutingTab() {
|
||||
loadRoutingLog();
|
||||
} catch(e) {
|
||||
$('routing-status').textContent = 'Load failed';
|
||||
$('routing-list').innerHTML = '<div style="color:var(--red);padding:10px">Failed to load routes</div>';
|
||||
$('routing-list').innerHTML = `<div style="color:var(--red);padding:10px">Failed to load routes: ${escHtml(e.message)}</div>`;
|
||||
status('Routing load failed');
|
||||
} finally {
|
||||
setBusyButton('routing-refresh-btn', false);
|
||||
|
||||
@ -621,11 +621,11 @@ if (document.body) _settingsHelpObserver.observe(document.body, { childList: tru
|
||||
|
||||
function validateVoiceId(v) { return /^[A-Za-z0-9_\-\.]+$/.test(v); }
|
||||
|
||||
$('voice-id-input').addEventListener('input', () => {
|
||||
$('voice-id-input')?.addEventListener('input', () => {
|
||||
const val = $('voice-id-input').value;
|
||||
const ok = val && validateVoiceId(val);
|
||||
$('voice-id-input').className = val ? (ok ? 'id-valid' : 'id-invalid') : '';
|
||||
$('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : '';
|
||||
if ($('voice-id-hint')) $('voice-id-hint').textContent = val && !ok ? 'Only A-Z, a-z, 0-9, _, -, . allowed' : '';
|
||||
});
|
||||
$('helper-apply-btn')?.addEventListener('click', () => {
|
||||
const name = $('name-input').value.trim();
|
||||
|
||||
190
static/js/sillytavern.js
Normal file
190
static/js/sillytavern.js
Normal file
@ -0,0 +1,190 @@
|
||||
// ── SillyTavern character-card compatibility ──────────────────────────────────
|
||||
//
|
||||
// Import and export characters in SillyTavern / TavernAI "Character Card" format
|
||||
// so a cast built here can be moved to/from SillyTavern (and the wider ecosystem
|
||||
// that follows the same spec: AI Character Editor, Chub, Agnai, …).
|
||||
//
|
||||
// Supported on import:
|
||||
// • V1 (legacy TavernAI): flat JSON { name, description, personality, … }
|
||||
// • V2: { spec:'chara_card_v2', spec_version:'2.0', data:{ … } }
|
||||
// • V3: { spec:'chara_card_v3', data:{ … } } (read; extra V3 fields preserved)
|
||||
// • PNG cards: JSON is base64 in a tEXt chunk (keyword 'chara' = V2, 'ccv3' = V3)
|
||||
//
|
||||
// Export is V2 JSON — the most widely accepted format. The assigned TTS voice is
|
||||
// stored under data.extensions.tts_voice so a round-trip keeps the casting.
|
||||
|
||||
// ── PNG tEXt chunk reader ─────────────────────────────────────────────────────
|
||||
// PNG = 8-byte signature then chunks of [len:4][type:4][data:len][crc:4].
|
||||
// SillyTavern stores the card as base64 JSON in a tEXt chunk whose keyword is
|
||||
// 'chara' (V2) or 'ccv3' (V3): data = keyword + 0x00 + base64text.
|
||||
function stReadPngText(buf) {
|
||||
const dv = new DataView(buf);
|
||||
const sig = [137, 80, 78, 71, 13, 10, 26, 10];
|
||||
for (let i = 0; i < 8; i++) if (dv.getUint8(i) !== sig[i]) throw new Error('Not a PNG file');
|
||||
let off = 8;
|
||||
const out = {};
|
||||
while (off + 8 <= dv.byteLength) {
|
||||
const len = dv.getUint32(off); off += 4;
|
||||
let type = '';
|
||||
for (let i = 0; i < 4; i++) type += String.fromCharCode(dv.getUint8(off + i));
|
||||
off += 4;
|
||||
if (type === 'tEXt') {
|
||||
const bytes = new Uint8Array(buf, off, len);
|
||||
let sep = bytes.indexOf(0);
|
||||
if (sep === -1) sep = len;
|
||||
let keyword = '';
|
||||
for (let i = 0; i < sep; i++) keyword += String.fromCharCode(bytes[i]);
|
||||
const text = new TextDecoder('latin1').decode(bytes.subarray(sep + 1));
|
||||
out[keyword] = text;
|
||||
}
|
||||
off += len + 4; // skip data + CRC
|
||||
if (type === 'IEND') break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function stB64ToJson(b64) {
|
||||
const bin = atob(b64.trim());
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return JSON.parse(new TextDecoder('utf-8').decode(bytes));
|
||||
}
|
||||
|
||||
// Normalise any card variant into the V2-style "data" object.
|
||||
function stNormalize(card) {
|
||||
if (!card || typeof card !== 'object') throw new Error('Invalid card JSON');
|
||||
if (card.data && typeof card.data === 'object' && card.data.name != null) return card.data; // V2/V3
|
||||
if (card.name != null) return card; // V1 flat
|
||||
throw new Error('Unrecognised character-card structure');
|
||||
}
|
||||
|
||||
// Parse a File (.json or .png) into the normalised data object.
|
||||
async function stParseFile(file) {
|
||||
const name = (file.name || '').toLowerCase();
|
||||
if (name.endsWith('.png')) {
|
||||
const buf = await file.arrayBuffer();
|
||||
const chunks = stReadPngText(buf);
|
||||
const raw = chunks['ccv3'] || chunks['chara'];
|
||||
if (!raw) throw new Error('PNG has no embedded character card');
|
||||
return stNormalize(stB64ToJson(raw));
|
||||
}
|
||||
// JSON (or anything else we try as text)
|
||||
const txt = await file.text();
|
||||
return stNormalize(JSON.parse(txt));
|
||||
}
|
||||
|
||||
// ── Mapping: SillyTavern data → our character sheet ───────────────────────────
|
||||
function stGuessGender(text) {
|
||||
const t = String(text || '').toLowerCase();
|
||||
const f = (t.match(/\b(she|her|female|woman|girl|mother|daughter|sister|queen|lady|mrs|miss)\b/g) || []).length;
|
||||
const m = (t.match(/\b(he|him|his|male|man|boy|father|son|brother|king|lord|mr|sir)\b/g) || []).length;
|
||||
if (f > m * 1.3) return 'female';
|
||||
if (m > f * 1.3) return 'male';
|
||||
return '';
|
||||
}
|
||||
|
||||
function stToSheet(data) {
|
||||
const tagList = Array.isArray(data.tags) ? data.tags : String(data.tags || '').split(',');
|
||||
const tags = tagList.map(t => String(t || '').trim()).filter(Boolean).join(', ');
|
||||
const ext = data.extensions || {};
|
||||
return {
|
||||
name: (data.name || data.char_name || 'Unnamed').trim(),
|
||||
archetype: (data.creator_notes || '').split('\n')[0].slice(0, 60) || '',
|
||||
backstory: data.description || data.char_persona || '',
|
||||
mannerisms: data.personality || '',
|
||||
arc_note: data.scenario || '',
|
||||
voice_pattern: data.mes_example || data.example_dialogue || data.first_mes || data.char_greeting || '',
|
||||
gender: ext.gender || stGuessGender((data.description || '') + ' ' + (data.personality || '')),
|
||||
note: data.creator_notes || '',
|
||||
tags,
|
||||
voice: ext.tts_voice || null,
|
||||
tier: 'supporting',
|
||||
st_card: data, // full original, kept for lossless round-trip export
|
||||
};
|
||||
}
|
||||
|
||||
// ── Mapping: our record → SillyTavern V2 card ─────────────────────────────────
|
||||
function stFromRecord(rec) {
|
||||
const sh = rec.sheet || {};
|
||||
const prev = sh.st_card || {};
|
||||
const descParts = [sh.physical, sh.clothing, sh.backstory, sh.alignment]
|
||||
.map(x => String(x || '').trim()).filter(Boolean);
|
||||
const persona = [sh.archetype, sh.mannerisms, sh.motivation, sh.fears]
|
||||
.map(x => String(x || '').trim()).filter(Boolean).join('\n');
|
||||
return {
|
||||
spec: 'chara_card_v2',
|
||||
spec_version: '2.0',
|
||||
data: {
|
||||
name: rec.name,
|
||||
description: prev.description || descParts.join('\n\n'),
|
||||
personality: persona || prev.personality || '',
|
||||
scenario: rec.book || prev.scenario || '',
|
||||
first_mes: prev.first_mes || '',
|
||||
mes_example: sh.voice_pattern || prev.mes_example || '',
|
||||
creator_notes: 'Exported from TTS Voice Creator' + (rec.book ? ' · ' + rec.book : ''),
|
||||
system_prompt: prev.system_prompt || '',
|
||||
post_history_instructions: prev.post_history_instructions || '',
|
||||
tags: String(rec.tags || rec.book || '').split(',').map(t => t.trim()).filter(Boolean),
|
||||
creator: prev.creator || '',
|
||||
character_version: prev.character_version || '1.0',
|
||||
extensions: Object.assign({}, prev.extensions, { tts_voice: rec.voice || '' }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function stDownloadJson(card, filename) {
|
||||
const blob = new Blob([JSON.stringify(card, null, 2)], { type: 'application/json' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = filename;
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 1000);
|
||||
}
|
||||
|
||||
// ── Public actions ────────────────────────────────────────────────────────────
|
||||
// Import one or more ST cards into a production (book). Returns count imported.
|
||||
async function stImportCards(book, fileList) {
|
||||
let n = 0;
|
||||
for (const file of fileList) {
|
||||
try {
|
||||
const data = await stParseFile(file);
|
||||
const sheet = stToSheet(data);
|
||||
if (typeof clUpsert === 'function') {
|
||||
await clUpsert(book || sheet.name, Object.assign({}, sheet, { tags: book || '' }));
|
||||
n++;
|
||||
}
|
||||
} catch (e) {
|
||||
if (typeof toast === 'function') toast('“' + (file.name || 'card') + '”: ' + (e.message || e), 'error');
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Trigger a file picker and import into `book`, then refresh the cast view.
|
||||
function stImportDialog(book, onDone) {
|
||||
const inp = document.createElement('input');
|
||||
inp.type = 'file';
|
||||
inp.accept = '.json,.png';
|
||||
inp.multiple = true;
|
||||
inp.onchange = async () => {
|
||||
if (!inp.files.length) return;
|
||||
const n = await stImportCards(book, inp.files);
|
||||
if (typeof toast === 'function') toast(n ? 'Imported ' + n + ' character' + (n > 1 ? 's' : '') : 'Nothing imported', n ? 'success' : 'error');
|
||||
if (typeof onDone === 'function') onDone();
|
||||
};
|
||||
inp.click();
|
||||
}
|
||||
|
||||
// Export a single character record as an ST V2 JSON card.
|
||||
function stExportRecord(rec) {
|
||||
const card = stFromRecord(rec);
|
||||
const safe = String(rec.name || 'character').replace(/[^\w\- ]+/g, '').trim().replace(/\s+/g, '_') || 'character';
|
||||
stDownloadJson(card, safe + '.card.json');
|
||||
}
|
||||
|
||||
window.stParseFile = stParseFile;
|
||||
window.stToSheet = stToSheet;
|
||||
window.stFromRecord = stFromRecord;
|
||||
window.stImportCards = stImportCards;
|
||||
window.stImportDialog = stImportDialog;
|
||||
window.stExportRecord = stExportRecord;
|
||||
@ -444,6 +444,38 @@ function switchTab(name) {
|
||||
if (name === 'getvoices') loadGetVoices();
|
||||
return true;
|
||||
}
|
||||
// ── Language detection ────────────────────────────────────────────────────────
|
||||
// Lightweight, dependency-free detector. First by script (CJK / Cyrillic /
|
||||
// Hangul), then by stop-word frequency for Latin-script languages. Returns a
|
||||
// full English language name ("German", "English", …) matching the design-language
|
||||
// <select>, or '' when there's too little signal.
|
||||
function detectLang(text) {
|
||||
const t = String(text || '').slice(0, 4000);
|
||||
if (!t.trim()) return '';
|
||||
if (/[가-힣]/.test(t)) return 'Korean';
|
||||
if (/[-ヿ]/.test(t)) return 'Japanese';
|
||||
if (/[一-鿿]/.test(t)) return 'Chinese';
|
||||
if (/[Ѐ-ӿ]/.test(t)) return 'Russian';
|
||||
const STOP = {
|
||||
English: ['the','and','of','to','a','in','that','is','was','he','for','it','with','as','his','on','be','at','by','she','you','not'],
|
||||
German: ['der','die','und','ich','das','nicht','ein','ist','sie','mit','dem','den','war','auf','für','des','eine','auch','als','er','wir','aber','noch'],
|
||||
French: ['le','la','les','et','de','un','une','des','est','que','pour','dans','qui','pas','plus','vous','avec','sur','son','ne','je','il'],
|
||||
Spanish: ['el','la','los','las','de','que','y','un','una','en','es','por','con','no','se','su','para','lo','como','más','pero','sus'],
|
||||
Italian: ['il','la','di','e','che','un','una','è','per','non','con','sono','del','le','si','lo','ma','come','più','anche','della','gli'],
|
||||
Portuguese: ['de','que','e','o','a','do','da','em','um','uma','não','os','para','com','por','como','mas','se','dos','das','ao','seu'],
|
||||
Dutch: ['de','het','een','en','van','ik','te','dat','die','in','is','niet','met','zijn','op','aan','voor','er','maar','om','ook','als'],
|
||||
};
|
||||
const words = (t.toLowerCase().match(/[a-zà-ÿ]+/g) || []);
|
||||
if (words.length < 8) return '';
|
||||
const counts = {}, sets = {};
|
||||
Object.keys(STOP).forEach(l => { counts[l] = 0; sets[l] = new Set(STOP[l]); });
|
||||
for (const w of words) for (const l in sets) if (sets[l].has(w)) counts[l]++;
|
||||
let best = '', bestN = 0;
|
||||
for (const l in counts) if (counts[l] > bestN) { bestN = counts[l]; best = l; }
|
||||
return bestN >= 3 ? best : 'English';
|
||||
}
|
||||
window.detectLang = detectLang;
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => tab.addEventListener('click', () => switchTab(tab.dataset.tab)));
|
||||
document.addEventListener('click', e => {
|
||||
const btn = e.target.closest('.backend-jump');
|
||||
|
||||
@ -1014,11 +1014,13 @@ $('benchmark-confirm-start')?.addEventListener('click', async () => {
|
||||
});
|
||||
|
||||
$('copy-active-voices-btn').addEventListener('click', async () => {
|
||||
const active = activeVoiceIds();
|
||||
if (!active.length) { toast('No active voices to copy', 'error'); return; }
|
||||
await copyText(active.join(', '));
|
||||
toast('Copied ' + active.length + ' active voices', 'success');
|
||||
status('Copied active voices to clipboard');
|
||||
const useSelected = _bulkSelected && _bulkSelected.size > 0;
|
||||
const ids = useSelected ? [..._bulkSelected] : activeVoiceIds();
|
||||
const label = useSelected ? `${ids.length} selected` : `${ids.length} active`;
|
||||
if (!ids.length) { toast('No voices to copy', 'error'); return; }
|
||||
await copyText(ids.join(', '));
|
||||
toast('Copied ' + label + ' voices', 'success');
|
||||
status('Copied ' + label + ' voices to clipboard');
|
||||
});
|
||||
|
||||
// Precompute speaker embeddings: fire a tiny synth per active voice so the TTS
|
||||
|
||||
@ -1,6 +1,19 @@
|
||||
(async function () {
|
||||
'use strict';
|
||||
|
||||
// Hard safety net: never show skeleton longer than 8 s regardless of what
|
||||
// fails below. A partial UI beats an indefinite spinner.
|
||||
var _skTimeout = setTimeout(function () {
|
||||
var sk = document.getElementById('app-skeleton');
|
||||
if (sk) {
|
||||
sk.classList.add('sk-hidden');
|
||||
// Fully remove from DOM — otherwise the (display:none) skeleton still sits
|
||||
// in the document and earlier reveal logic would skip removal.
|
||||
setTimeout(function () { if (sk.parentNode) sk.parentNode.removeChild(sk); }, 300);
|
||||
document.querySelectorAll('.page-section').forEach(function (s) { s.style.display = ''; });
|
||||
}
|
||||
}, 8000);
|
||||
|
||||
const SECTIONS = [
|
||||
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
||||
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
||||
@ -22,18 +35,26 @@
|
||||
var _savedSection = _storedSection();
|
||||
var _activeSection = _hashSection() || ((_savedSection && SECTIONS.indexOf(_savedSection) !== -1) ? _savedSection : 's-voices');
|
||||
|
||||
// ── Fetch app version once for JS cache-busting ──────────────────────────
|
||||
// ── App version for cache-busting ────────────────────────────────────────
|
||||
// Read from the already-loaded style.css href — no extra network round trip.
|
||||
// Falls back to fetching /api/version only if the tag isn't found.
|
||||
var _appVersion = 'dev';
|
||||
try {
|
||||
var _vr = await fetch('/api/version');
|
||||
var _vd = await _vr.json();
|
||||
_appVersion = _vd.version || _appVersion;
|
||||
} catch (_) {}
|
||||
var _styleHref = (document.querySelector('link[href*="style.css"]') || {}).getAttribute ? document.querySelector('link[href*="style.css"]').getAttribute('href') : '';
|
||||
var _styleMatch = _styleHref && _styleHref.match(/[?&]v=([^&]+)/);
|
||||
if (_styleMatch) {
|
||||
_appVersion = _styleMatch[1];
|
||||
} else {
|
||||
// Fallback: fetch from API (only on first ever load before style tag exists)
|
||||
try {
|
||||
var _vr = await fetch('/api/version');
|
||||
var _vd = await _vr.json();
|
||||
_appVersion = _vd.version || _appVersion;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
var _ts = Date.now(); // sections always fresh during a session
|
||||
// Append session timestamp so that a version bump + container restart
|
||||
// always invalidates previously cached JS, even on the same version string.
|
||||
_appVersion = _appVersion + '-' + _ts;
|
||||
// Session timestamp used ONLY for section HTML files (never cached by SW anyway).
|
||||
// JS/CSS use the version string so the service worker cache stays valid across reloads.
|
||||
var _ts = Date.now();
|
||||
|
||||
function _inject(id, html) {
|
||||
var el = document.getElementById(id);
|
||||
@ -103,9 +124,8 @@
|
||||
// settings.js completes, so this await is typically instant.
|
||||
await _bgSections;
|
||||
|
||||
// C — feature modules (executed in order; share a global scope).
|
||||
// Opt-in single bundle (npm run minify) keeps these as one minified request;
|
||||
// default loads them individually so editing static/js/* is live without a build.
|
||||
// C — feature modules: always try the bundle first (one request instead of 21).
|
||||
// Falls back to individual files if the bundle is missing or fails to load.
|
||||
var _featureFiles = [
|
||||
'/static/js/voice-picker.js',
|
||||
'/static/js/benchmark-voice-picker.js',
|
||||
@ -127,31 +147,38 @@
|
||||
'/static/js/audiobook.js',
|
||||
'/static/js/character-sheets.js',
|
||||
'/static/js/characters-library.js',
|
||||
'/static/js/sillytavern.js',
|
||||
'/static/js/library.js',
|
||||
];
|
||||
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
|
||||
var _bundleOk = false;
|
||||
if (_useBundle) {
|
||||
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
|
||||
catch (_) { _bundleOk = false; } // not built / missing → fall back to per-file
|
||||
}
|
||||
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
|
||||
catch (_) { _bundleOk = false; }
|
||||
if (!_bundleOk) await _loadBatch(_featureFiles);
|
||||
|
||||
// D — init (needs everything above to be defined)
|
||||
await _load('/static/js/init.js');
|
||||
try { await _load('/static/js/init.js'); } catch (_) {}
|
||||
|
||||
// F — navigation (must run before revealing UI so the correct section shows)
|
||||
await _load('/static/nav.js');
|
||||
try { await _load('/static/nav.js'); } catch (_) {}
|
||||
|
||||
// ── Remove skeleton, reveal sections — as soon as nav is ready ──────────
|
||||
var sk = document.getElementById('app-skeleton');
|
||||
if (sk) {
|
||||
sk.classList.add('sk-hidden');
|
||||
setTimeout(function () { if (sk.parentNode) sk.parentNode.removeChild(sk); }, 300);
|
||||
// ── Remove skeleton, reveal sections ────────────────────────────────────
|
||||
// Always runs — even if a script above failed to load, showing a partial UI
|
||||
// is far better than leaving the skeleton on screen indefinitely.
|
||||
_revealApp();
|
||||
|
||||
function _revealApp() {
|
||||
clearTimeout(_skTimeout);
|
||||
var sk = document.getElementById('app-skeleton');
|
||||
// Always remove the skeleton if it's still in the DOM — never gate on
|
||||
// sk-hidden (the 8s fallback may have added that class without removing it).
|
||||
if (sk) {
|
||||
sk.classList.add('sk-hidden');
|
||||
setTimeout(function () { if (sk.parentNode) sk.parentNode.removeChild(sk); }, 300);
|
||||
}
|
||||
document.querySelectorAll('.page-section').forEach(function (s) {
|
||||
s.style.display = '';
|
||||
});
|
||||
}
|
||||
document.querySelectorAll('.page-section').forEach(function (s) {
|
||||
s.style.display = '';
|
||||
});
|
||||
|
||||
// ── i18n: add the language picker and translate the (now fully-rendered) UI ──
|
||||
if (window.initLangPicker) window.initLangPicker();
|
||||
|
||||
@ -77,6 +77,7 @@
|
||||
{ tree: 'nav-tags-tree', chevron: 'nav-tags-chevron' },
|
||||
{ tree: 'nav-actions-tree', chevron: 'nav-actions-chevron' },
|
||||
{ tree: 'nav-speak-tree', chevron: 'nav-speak-chevron' },
|
||||
{ tree: 'nav-reader-tree', chevron: 'nav-reader-chevron', section: 's-reader' },
|
||||
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
|
||||
{ tree: 'nav-library-tree', chevron: 'nav-library-chevron', section: 's-library' },
|
||||
{ tree: 'nav-setup-tree', chevron: 'nav-setup-chevron' },
|
||||
@ -113,6 +114,22 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Show/hide an animated dot on a nav section head to signal a background process.
|
||||
// Call setNavBusy('s-reader', true) when synthesis/playback starts, false when done.
|
||||
window.setNavBusy = function (sectionId, busy) {
|
||||
var el = document.querySelector('[data-nav-section="' + sectionId + '"]');
|
||||
if (!el) return;
|
||||
var dot = el.querySelector('.nav-busy-dot');
|
||||
if (busy && !dot) {
|
||||
dot = document.createElement('span');
|
||||
dot.className = 'nav-busy-dot';
|
||||
dot.title = 'Running…';
|
||||
el.appendChild(dot);
|
||||
} else if (!busy && dot) {
|
||||
dot.remove();
|
||||
}
|
||||
};
|
||||
|
||||
window.toggleNavTree = function (treeId, chevronId) {
|
||||
_treeOpen[treeId] = !_treeOpen[treeId];
|
||||
localStorage.setItem('ttsvc_trees', JSON.stringify(_treeOpen));
|
||||
@ -124,13 +141,19 @@
|
||||
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
|
||||
if (sectionId !== 's-reader' && typeof window.readerStop === 'function') window.readerStop();
|
||||
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
||||
// If a cast session panel is active, restore it instead of showing the main PDF view
|
||||
// If a cast session panel is active, restore it instead of showing the main PDF
|
||||
// view — UNLESS we're opening a fresh book (which sets _readerSuppressCastRestore),
|
||||
// in which case a leftover panel must not hijack the reader into an empty cast view.
|
||||
if (sectionId === 's-reader') {
|
||||
var _castPnl = document.getElementById('reader-audiobook-panel');
|
||||
if (_castPnl && _castPnl.querySelector('#ab-cv-feed')) {
|
||||
setTimeout(function () {
|
||||
if (typeof window.showReaderView === 'function') window.showReaderView('cast');
|
||||
}, 0);
|
||||
if (window._readerSuppressCastRestore) {
|
||||
window._readerSuppressCastRestore = false;
|
||||
} else {
|
||||
var _castPnl = document.getElementById('reader-audiobook-panel');
|
||||
if (_castPnl && _castPnl.querySelector('#ab-cv-feed')) {
|
||||
setTimeout(function () {
|
||||
if (typeof window.showReaderView === 'function') window.showReaderView('cast');
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');
|
||||
@ -155,9 +178,10 @@
|
||||
applySettingsPage(window._settingsSidebarCat || 'connections');
|
||||
}
|
||||
|
||||
// When entering engines, show the active sub-page (default: llm)
|
||||
// When entering engines, show the active sub-page (default: llm) and load containers
|
||||
if (sectionId === 's-llms') {
|
||||
applyEnginesPage(window._enginesSidebarCat || 'llm');
|
||||
if (typeof loadLocalContainers === 'function') loadLocalContainers();
|
||||
}
|
||||
|
||||
var main = document.getElementById('main-content');
|
||||
@ -304,7 +328,7 @@
|
||||
window._enginesSidebarCat = cat;
|
||||
localStorage.setItem('ttsvc_engines_cat', cat);
|
||||
navTo('s-llms'); // showSection will call applyEnginesPage(cat)
|
||||
if ((cat === 'tts' || cat === 'stt') && typeof loadLocalContainers === 'function') loadLocalContainers();
|
||||
if (typeof loadLocalContainers === 'function') loadLocalContainers();
|
||||
};
|
||||
|
||||
// ── Rehearser tree sub-navigation ────────────────────────────────────────
|
||||
|
||||
@ -22,10 +22,7 @@
|
||||
<div id="lib-plays-list" class="reh-bookshelf"></div>
|
||||
</div>
|
||||
|
||||
<!-- Characters / Cast — navigates to s-characters to avoid duplicate IDs -->
|
||||
<!-- Characters / Cast — production-grouped character cards with sheet info + voice selector -->
|
||||
<div class="lib-panel" data-library-panel="characters">
|
||||
<div style="padding:32px;text-align:center;color:var(--subtext);font-size:14px">
|
||||
<span class="mdi mdi-account-box-multiple-outline" style="font-size:40px;display:block;margin-bottom:12px;opacity:0.4"></span>
|
||||
<p>Opening Characters…</p>
|
||||
</div>
|
||||
<div id="lib-chars-list"></div>
|
||||
</div>
|
||||
|
||||
@ -125,7 +125,7 @@
|
||||
<button class="btn-secondary vl-tb-btn" id="sync-voice-folders-btn" title="Sync active/hidden folders">Sync</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="calculate-db-btn" title="Calculate dBFS">Calc dB</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="benchmark-voices-btn" title="Benchmark TTS speed">Benchmark</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy active voice names">Copy active</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="copy-active-voices-btn" title="Copy selected voices (or all active if none checked)">Copy selected</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="precompute-embeddings-btn" title="Warm all active voices so the TTS engine pre-computes & caches each speaker embedding (.pt) — makes first playback instant"><span class="mdi mdi-flash-outline"></span> Precompute</button>
|
||||
<button class="btn-secondary vl-tb-btn" id="seed-batch-all-btn" title="Generate & cache Seed Finder samples for every active voice (skips already-cached; resumable)"><span class="mdi mdi-dice-multiple-outline"></span> Batch seeds</button>
|
||||
</div>
|
||||
|
||||
588
static/style.css
588
static/style.css
@ -188,6 +188,29 @@ body {
|
||||
.nav-tree-item .mdi { font-size: 14px; flex-shrink: 0; }
|
||||
.nav-tree-item:hover { background: var(--panel); color: var(--text); }
|
||||
.nav-tree-item.is-active { color: var(--accent); font-weight: 700; }
|
||||
/* Only show sub-item highlight when the parent section head is the CURRENT page.
|
||||
When you're on a different section the remembered sub-item stays stored but
|
||||
should not look "active" — it just creates visual noise. */
|
||||
.nav-item:not(.active) ~ .nav-tree .nav-tree-item.is-active,
|
||||
.nav-item:not(.active) ~ .nav-tree .nav-tree-head.nav-subhead.active {
|
||||
color: var(--subtext); font-weight: normal;
|
||||
}
|
||||
/* Suppress sub-item highlight when its section-head is not the current section.
|
||||
Covers Language Models, Books, Stage etc. that are hardcoded is-active in HTML. */
|
||||
[data-nav-section]:not(.active) + .nav-tree .nav-tree-item.is-active {
|
||||
color: var(--subtext); background: transparent; font-weight: normal;
|
||||
}
|
||||
|
||||
/* ── Background-process busy indicator ──────────────────────────────────────
|
||||
A small animated dot shown on a nav-item or nav-tree-head when a background
|
||||
task (synthesis, benchmark, cast, playback) is running in that section.
|
||||
Set via window.setNavBusy(sectionId, true|false) in nav.js. */
|
||||
@keyframes nav-busy-pulse { 0%,100%{opacity:.5;transform:scale(.9)} 50%{opacity:1;transform:scale(1.15)} }
|
||||
.nav-busy-dot {
|
||||
display: inline-block; width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--accent); flex-shrink: 0; margin-left: auto;
|
||||
animation: nav-busy-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
/* Current page/section within its parent group */
|
||||
.nav-tree-item.active {
|
||||
background: rgba(37,99,235,0.08); color: var(--accent); font-weight: 700;
|
||||
@ -317,8 +340,7 @@ body {
|
||||
/* ── Collapsible cards ───────────────────────────────────────────────────── */
|
||||
.card-collapse-h2 { cursor: pointer; user-select: none; display: flex; align-items: center; gap: 7px; }
|
||||
.card-collapse-h2:hover { opacity: .8; }
|
||||
.card-chev { display: inline-block; flex-shrink: 0; width: 16px; height: 16px; border-radius: 4px; background: var(--panel); border: 1px solid var(--border); color: var(--accent); font-size: 10px; line-height: 16px; text-align: center; transition: transform .2s; }
|
||||
.card-chev::before { content: '▾'; }
|
||||
.card-chev { display: inline-flex; align-items: center; justify-content: center; flex-shrink: 0; width: 18px; height: 18px; border-radius: 4px; background: var(--panel); border: 1px solid var(--border); color: var(--accent); font-size: 13px; line-height: 1; transition: transform .2s; }
|
||||
.card-col-closed .card-chev { transform: rotate(-90deg); }
|
||||
/* Sync <details> inside cards to the same chevron look */
|
||||
.card > details > summary { list-style: none; cursor: pointer; user-select: none; display: flex; align-items: center; gap: 7px; }
|
||||
@ -525,6 +547,345 @@ audio { width: 100%; }
|
||||
.lib-empty .mdi { font-size:42px; opacity:.4; }
|
||||
.lib-empty p { margin:0; font-size:13px; line-height:1.5; }
|
||||
.lib-empty-hint { opacity:.8; }
|
||||
.lib-chars-loading { padding:32px; text-align:center; color:var(--subtext); font-size:13px; }
|
||||
|
||||
/* ── Characters / Cast view ─────────────────────────────────────────────────── */
|
||||
.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
|
||||
.lib-chars-production { margin-bottom:28px; }
|
||||
.lib-char-card .lib-char-export {
|
||||
position:absolute; top:6px; right:6px; z-index:2;
|
||||
width:24px; height:24px; border-radius:5px; cursor:pointer;
|
||||
border:1px solid rgba(255,255,255,.3); background:rgba(0,0,0,.25); color:rgba(255,255,255,.8);
|
||||
display:flex; align-items:center; justify-content:center; opacity:0; transition:opacity .12s, color .12s, border-color .12s;
|
||||
}
|
||||
.lib-char-card:hover .lib-char-export { opacity:1; }
|
||||
.lib-char-card .lib-char-export:hover { background:rgba(0,0,0,.5); color:#fff; border-color:rgba(255,255,255,.6); }
|
||||
.lib-chars-prod-head {
|
||||
display: flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:8px;
|
||||
padding:10px 14px; border-radius:8px 8px 0 0; gap:10px;
|
||||
background: linear-gradient(90deg, var(--pk1,#4f46e5), var(--pk2,#7c3aed));
|
||||
color: #fff;
|
||||
}
|
||||
.lib-chars-prod-title { font-weight:600; font-size:14px; display:flex; align-items:center; gap:8px; }
|
||||
.lib-chars-prod-title .mdi { opacity:.8; }
|
||||
.lib-chars-prod-actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.lib-chars-prod-actions .btn-secondary {
|
||||
background: rgba(255,255,255,.15); border-color: rgba(255,255,255,.3); color:#fff; font-size:11px;
|
||||
}
|
||||
.lib-chars-prod-actions .btn-secondary:hover { background: rgba(255,255,255,.28); }
|
||||
.lib-chars-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 12px; padding:16px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border); border-top: none; border-radius: 0 0 8px 8px;
|
||||
}
|
||||
/* Portrait-style character card */
|
||||
.lib-char-card {
|
||||
display: flex; flex-direction:column; align-items:stretch;
|
||||
background: var(--surface); cursor:pointer; position:relative;
|
||||
border-radius:10px; overflow:hidden; border:1px solid var(--border);
|
||||
transition: transform .13s, box-shadow .13s;
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.08);
|
||||
}
|
||||
.lib-char-card:hover { transform:translateY(-3px); box-shadow:0 8px 22px rgba(0,0,0,.16); }
|
||||
/* Coloured banner at top of each card */
|
||||
.lib-char-card-banner {
|
||||
height:90px; display:flex; align-items:center; justify-content:center; position:relative;
|
||||
background: linear-gradient(135deg, var(--ch1,#4f46e5) 0%, var(--ch2,#7c3aed) 100%);
|
||||
}
|
||||
.lib-char-avatar {
|
||||
width:72px; height:72px; border-radius:50%;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:28px; font-weight:700; color:#fff; letter-spacing:0;
|
||||
border:3px solid rgba(255,255,255,.35); box-shadow:0 3px 10px rgba(0,0,0,.35);
|
||||
}
|
||||
.lib-char-body { flex:1; padding:12px 14px 14px; display:flex; flex-direction:column; }
|
||||
.lib-char-name {
|
||||
font-weight:700; font-size:15px; text-align:center; margin-bottom:2px;
|
||||
display:flex; align-items:center; justify-content:center; gap:5px; flex-wrap:wrap;
|
||||
}
|
||||
.lib-char-tier { font-size:9px; font-weight:700; letter-spacing:.5px; text-transform:uppercase;
|
||||
padding:2px 6px; border-radius:10px; }
|
||||
.lib-char-tier.main { background: rgba(245,158,11,.2); color: #d97706; }
|
||||
.lib-char-tier.support { background: rgba(99,102,241,.15); color: var(--accent); }
|
||||
.lib-char-archetype { font-size:12px; color:var(--subtext); text-align:center; margin:2px 0 8px; font-style:italic; }
|
||||
.lib-char-snippet { font-size:12px; line-height:1.5; color:var(--subtext); flex:1; margin-bottom:8px;
|
||||
display:-webkit-box; -webkit-line-clamp:4; -webkit-box-orient:vertical; overflow:hidden; }
|
||||
.lib-char-divider { height:1px; background:var(--border); margin:8px -14px; }
|
||||
/* Voice row — compact, at bottom of card */
|
||||
.lib-char-voice-row { display:flex; align-items:center; gap:4px; flex-wrap:wrap; margin-top:6px; }
|
||||
.lib-char-voice-icon { color:var(--accent); font-size:14px; flex-shrink:0; }
|
||||
.lib-char-voice-label { font-size:12px; flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.lib-char-voice-row button {
|
||||
flex-shrink:0; font-size:10px; padding:2px 8px; border-radius:4px; cursor:pointer;
|
||||
border: 1px solid var(--border); background: var(--panel); color:var(--text);
|
||||
transition: background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.lib-char-pick-voice:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.lib-char-auto-voice:hover { border-color: var(--green,#22c55e); color: var(--green,#22c55e); }
|
||||
.lib-char-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; }
|
||||
.lib-char-gen-voice:hover { border-color:#a855f7; color:#a855f7; }
|
||||
.lib-char-voice-label { flex-basis:100%; order:-1; }
|
||||
.lib-char-voice-row { row-gap:4px; }
|
||||
|
||||
/* Alignment bar */
|
||||
.lib-char-align { display:flex; align-items:center; gap:5px; margin:6px 0 4px; }
|
||||
.lib-char-align-evil { font-size:9px; color:#555; flex-shrink:0; line-height:1; }
|
||||
.lib-char-align-good { font-size:9px; color:#ddd; flex-shrink:0; line-height:1; }
|
||||
.lib-char-align-bar {
|
||||
flex:1; height:7px; border-radius:4px; position:relative; cursor:default;
|
||||
background: linear-gradient(to right, #111 0%, #444 30%, #888 50%, #ccc 70%, #f0f0f0 100%);
|
||||
box-shadow: inset 0 1px 2px rgba(0,0,0,.4);
|
||||
}
|
||||
.lib-char-align-dot {
|
||||
position:absolute; top:50%; transform:translate(-50%,-50%);
|
||||
width:11px; height:11px; border-radius:50%;
|
||||
background: #fff; border:2px solid rgba(0,0,0,.6);
|
||||
box-shadow: 0 0 0 1px rgba(255,255,255,.4), 0 1px 3px rgba(0,0,0,.5);
|
||||
transition: left .3s ease;
|
||||
}
|
||||
.lib-char-align-arrow { font-size:13px; flex-shrink:0; line-height:1; font-style:normal; }
|
||||
|
||||
/* Most-interacted character dots */
|
||||
.lib-char-rels { display:flex; align-items:center; gap:5px; margin:5px 0 7px; flex-wrap:wrap; }
|
||||
.lib-char-rel-dot {
|
||||
width:24px; height:24px; border-radius:50%; display:inline-flex; align-items:center;
|
||||
justify-content:center; font-size:10px; font-weight:700; color:#fff; flex-shrink:0;
|
||||
cursor:default; border:1.5px solid rgba(255,255,255,.15);
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.3);
|
||||
}
|
||||
|
||||
/* ── Character detail modal (D&D / LARP card) ───────────────────────────────── */
|
||||
.lib-char-detail-ov {
|
||||
position:fixed; inset:0; z-index:1100;
|
||||
background: rgba(0,0,0,.78); backdrop-filter:blur(6px);
|
||||
display:flex; align-items:flex-start; justify-content:center;
|
||||
padding:32px 16px 48px; overflow-y:auto;
|
||||
}
|
||||
.lib-char-detail-box {
|
||||
background: var(--surface); border-radius:16px; width:100%; max-width:860px;
|
||||
box-shadow: 0 32px 80px rgba(0,0,0,.6); overflow:hidden; flex-shrink:0;
|
||||
}
|
||||
.lcd-header {
|
||||
display:flex; gap:20px; align-items:flex-start; padding:28px 28px 24px;
|
||||
background: linear-gradient(135deg, var(--lc1,#4f46e5) 0%, var(--lc2,#7c3aed) 100%);
|
||||
color:#fff; position:relative;
|
||||
}
|
||||
.lcd-avatar {
|
||||
flex-shrink:0; width:90px; height:90px; border-radius:50%;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:38px; font-weight:700; color:#fff; letter-spacing:0;
|
||||
border:3px solid rgba(255,255,255,.4); box-shadow: 0 6px 18px rgba(0,0,0,.35);
|
||||
overflow:hidden; cursor:pointer;
|
||||
}
|
||||
.lcd-avatar:hover::after {
|
||||
content:'\F04F4'; font-family:'Material Design Icons'; font-size:28px;
|
||||
position:absolute; inset:0; border-radius:50%;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
background:rgba(0,0,0,.45); color:#fff;
|
||||
}
|
||||
.lcd-avatar { position:relative; }
|
||||
.lcd-avatar img { width:100%; height:100%; object-fit:cover; }
|
||||
.lcd-header-body { flex:1; min-width:0; }
|
||||
.lcd-name { font-size:26px; font-weight:700; line-height:1.15; margin-bottom:5px; }
|
||||
.lcd-aliases { font-size:14px; opacity:.72; margin-bottom:7px; font-style:italic; }
|
||||
.lcd-archetype { font-size:17px; opacity:.88; margin-bottom:12px; font-weight:500; }
|
||||
.lcd-tier-gender { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
|
||||
.lcd-tier { font-size:12px; font-weight:700; letter-spacing:.6px; text-transform:uppercase;
|
||||
padding:4px 12px; border-radius:14px; background:rgba(255,255,255,.22); }
|
||||
.lcd-gender { font-size:14px; opacity:.82; }
|
||||
.lcd-header-btns { display:flex; gap:8px; flex-shrink:0; align-self:flex-start; }
|
||||
.lcd-header-btns button {
|
||||
background:rgba(255,255,255,.18); border:1px solid rgba(255,255,255,.32); color:#fff;
|
||||
border-radius:8px; padding:7px 16px; font-size:14px; cursor:pointer;
|
||||
transition:background .12s; display:flex; align-items:center; gap:6px;
|
||||
}
|
||||
.lcd-header-btns button:hover { background:rgba(255,255,255,.32); }
|
||||
/* Body with alternating section backgrounds */
|
||||
.lcd-body { padding:0; }
|
||||
.lcd-align-section {
|
||||
padding:20px 28px 18px; border-bottom:1px solid var(--border);
|
||||
background: var(--panel);
|
||||
}
|
||||
.lcd-align-bar-wrap { display:flex; align-items:center; gap:12px; margin:10px 0 6px; }
|
||||
.lcd-align-label { font-size:14px; color:var(--subtext); flex-shrink:0; font-weight:600; }
|
||||
.lcd-align-bar {
|
||||
flex:1; height:12px; border-radius:6px; position:relative;
|
||||
background: linear-gradient(to right, #1a1a1a 0%, #555 30%, #888 50%, #bbb 70%, #efefef 100%);
|
||||
box-shadow: inset 0 2px 4px rgba(0,0,0,.5);
|
||||
}
|
||||
.lcd-align-dot {
|
||||
position:absolute; top:50%; transform:translate(-50%,-50%);
|
||||
width:20px; height:20px; border-radius:50%;
|
||||
background:#fff; border:3px solid rgba(0,0,0,.5);
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,.6), 0 3px 8px rgba(0,0,0,.55);
|
||||
}
|
||||
.lcd-align-arc { font-size:15px; font-weight:500; margin-top:7px; }
|
||||
.lcd-arc-note { font-size:14px; color:var(--subtext); font-style:italic; margin-top:5px; line-height:1.6; }
|
||||
/* Two-column section grid */
|
||||
.lcd-sections { display:grid; grid-template-columns:1fr 1fr; }
|
||||
@media (max-width:620px) { .lcd-sections { grid-template-columns:1fr; } }
|
||||
.lcd-section {
|
||||
padding:22px 28px; border-bottom:1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.lcd-section:nth-child(odd) { border-right:1px solid var(--border); }
|
||||
/* Alternating panel rows */
|
||||
.lcd-section:nth-child(4n+1),
|
||||
.lcd-section:nth-child(4n+2) { background: var(--panel); }
|
||||
.lcd-section-full {
|
||||
padding:22px 28px; border-bottom:1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.lcd-section-full:nth-child(even) { background: var(--panel); }
|
||||
.lcd-section-label {
|
||||
font-size:11px; font-weight:700; letter-spacing:.9px; text-transform:uppercase;
|
||||
color:var(--accent); margin-bottom:14px; display:flex; align-items:center; gap:7px;
|
||||
}
|
||||
.lcd-field { margin-bottom:16px; }
|
||||
.lcd-field:last-child { margin-bottom:0; }
|
||||
.lcd-field-label {
|
||||
font-size:11px; font-weight:700; color:var(--subtext); text-transform:uppercase;
|
||||
letter-spacing:.5px; margin-bottom:5px;
|
||||
}
|
||||
.lcd-field-value { font-size:15px; line-height:1.65; color:var(--text); white-space:pre-wrap; }
|
||||
.lcd-rels-dots { display:flex; flex-wrap:wrap; gap:8px; margin-top:10px; }
|
||||
.lcd-rel-dot {
|
||||
width:36px; height:36px; border-radius:50%; display:inline-flex; align-items:center;
|
||||
justify-content:center; font-size:14px; font-weight:700; color:#fff;
|
||||
border:2px solid rgba(255,255,255,.18); box-shadow:0 3px 7px rgba(0,0,0,.35);
|
||||
cursor:default;
|
||||
}
|
||||
.lcd-voice-section {
|
||||
padding:22px 28px 28px; background:var(--panel);
|
||||
}
|
||||
.lcd-voice-row { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-top:10px; }
|
||||
.lcd-voice-label { font-size:15px; flex:1; min-width:140px; }
|
||||
.lcd-voice-row button {
|
||||
font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer;
|
||||
border:1px solid var(--border); background:var(--surface); color:var(--text);
|
||||
transition:background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.lcd-pick-voice:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.lcd-auto-voice:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); }
|
||||
.lcd-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; }
|
||||
.lcd-gen-voice:hover { border-color:#a855f7; color:#a855f7; }
|
||||
.lcd-analysis {
|
||||
background:var(--panel); border-top:1px solid var(--border);
|
||||
padding:20px 28px; font-size:14px; line-height:1.7; color:var(--subtext); white-space:pre-wrap;
|
||||
}
|
||||
/* Voice section at top of modal body */
|
||||
.lcd-voice-top {
|
||||
padding:20px 28px 18px; border-bottom:1px solid var(--border);
|
||||
background:var(--surface); display:flex; align-items:center; gap:12px; flex-wrap:wrap;
|
||||
}
|
||||
.lcd-voice-top .lcd-section-label { margin-bottom:0; flex-shrink:0; }
|
||||
.lcd-voice-top .lcd-voice-label { font-size:14px; flex:1; min-width:120px; }
|
||||
.lcd-voice-top button {
|
||||
font-size:13px; padding:6px 16px; border-radius:7px; cursor:pointer;
|
||||
border:1px solid var(--border); background:var(--panel); color:var(--text);
|
||||
transition:background .12s, border-color .12s, color .12s;
|
||||
}
|
||||
.lcd-voice-top .lcd-pick-voice:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.lcd-voice-top .lcd-auto-voice:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); }
|
||||
.lcd-voice-top .lcd-online-voice:hover { border-color:#0ea5e9; color:#0ea5e9; }
|
||||
.lcd-voice-top .lcd-gen-voice:hover { border-color:#a855f7; color:#a855f7; }
|
||||
/* Sources section */
|
||||
.lcd-sources {
|
||||
padding:20px 28px; background:var(--panel); border-top:1px solid var(--border);
|
||||
}
|
||||
.lcd-source-list { display:flex; flex-direction:column; gap:8px; margin-top:10px; }
|
||||
.lcd-source-item {
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:8px;
|
||||
padding:10px 14px;
|
||||
}
|
||||
.lcd-source-page { font-size:11px; font-weight:700; color:var(--accent); margin-bottom:4px; text-transform:uppercase; letter-spacing:.4px; }
|
||||
.lcd-source-quote { font-size:13px; line-height:1.55; color:var(--text); font-style:italic; }
|
||||
.lcd-source-hint { font-size:11px; color:var(--subtext); margin-top:3px; }
|
||||
.lcd-sources-hint { font-size:10px; opacity:.45; font-weight:400; letter-spacing:0; }
|
||||
.lcd-source-clickable { cursor:pointer; transition:background .12s, border-color .12s; }
|
||||
.lcd-source-clickable:hover { background:rgba(var(--accent-rgb,124,106,247),.12); border-color:var(--accent); }
|
||||
|
||||
/* ── Character detail page (full-page layout, no modal) ─────────────────── */
|
||||
/*
|
||||
Fixed height so each panel scrolls independently.
|
||||
The outer content wrapper already has overflow-y:auto (line ~284),
|
||||
so sticky doesn't work inside it. Instead we give .lib-char-page a
|
||||
real viewport height and let each column overflow internally.
|
||||
*/
|
||||
.lib-char-page {
|
||||
display:flex; flex-direction:row;
|
||||
height:calc(100dvh - 148px); /* viewport minus app header + section tabs */
|
||||
min-height:400px; overflow:hidden;
|
||||
}
|
||||
.lib-cpg-main { flex:1; overflow-y:auto; min-width:0; padding-bottom:60px; }
|
||||
.lib-cpg-back {
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
background:none; border:none; cursor:pointer; color:var(--accent);
|
||||
font-size:13px; padding:14px 20px 8px; opacity:.8;
|
||||
}
|
||||
.lib-cpg-back:hover { opacity:1; }
|
||||
/* Remove the outer box from the modal — header fills edge-to-edge */
|
||||
.lib-char-page .lcd-header { border-radius:0; }
|
||||
.lib-char-page .lcd-body { padding:0; }
|
||||
/* Inline-editable fields inside the detail page */
|
||||
.lcd-field-editable {
|
||||
outline:none; border-radius:4px; cursor:text;
|
||||
padding:2px 5px; margin:-2px -5px;
|
||||
transition:background .15s, box-shadow .15s;
|
||||
min-height:1.2em;
|
||||
}
|
||||
.lcd-field-editable:hover { background:rgba(255,255,255,.05); }
|
||||
.lcd-field-editable:focus { background:rgba(255,255,255,.09); box-shadow:inset 0 -2px 0 var(--accent); }
|
||||
/* Header name/aliases/archetype editable */
|
||||
.lcd-header [contenteditable] { outline:none; cursor:text; }
|
||||
.lcd-header [contenteditable]:hover { text-decoration:underline; text-underline-offset:3px; text-decoration-style:dotted; }
|
||||
.lcd-header [contenteditable]:focus { text-decoration:none; background:rgba(0,0,0,.2); border-radius:4px; padding:0 4px; }
|
||||
/* Alignment slider */
|
||||
.lcd-align-slider { flex:1; height:6px; accent-color:var(--accent,#7c6af7); cursor:pointer; }
|
||||
.lcd-align-slider-val { font-size:13px; font-weight:600; opacity:.7; flex-shrink:0; min-width:48px; text-align:right; }
|
||||
|
||||
/* Right sidebar — independent scroll, no sticky needed (fixed-height parent) */
|
||||
.lib-cpg-sidebar {
|
||||
width:240px; flex-shrink:0;
|
||||
border-left:3px solid var(--border);
|
||||
box-shadow:-4px 0 12px rgba(0,0,0,.18);
|
||||
overflow-y:auto; background:var(--panel);
|
||||
}
|
||||
.lib-cpg-sidebar-title {
|
||||
padding:12px 14px 8px; font-size:10px; font-weight:700;
|
||||
letter-spacing:.07em; text-transform:uppercase; opacity:.45; border-bottom:1px solid var(--border);
|
||||
}
|
||||
.lib-cpg-sidebar-item {
|
||||
display:flex; align-items:center; gap:9px; padding:8px 12px;
|
||||
cursor:pointer; transition:background .12s; border-bottom:1px solid rgba(255,255,255,.03);
|
||||
}
|
||||
.lib-cpg-sidebar-item:hover, .lib-cpg-sidebar-item.is-active { background:var(--surface); }
|
||||
.lib-cpg-sidebar-item.is-active { border-left:2px solid var(--accent); padding-left:10px; }
|
||||
.lib-cpg-sidebar-dot {
|
||||
width:26px; height:26px; border-radius:50%; flex-shrink:0;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:11px; font-weight:700; color:#fff;
|
||||
}
|
||||
.lib-cpg-sidebar-name { flex:1; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.lib-cpg-sidebar-count { font-size:11px; opacity:.4; flex-shrink:0; }
|
||||
|
||||
/* Voice picker popup */
|
||||
.lib-voice-picker-popup {
|
||||
position:absolute; bottom:calc(100% + 4px); left:0; right:0; z-index:200;
|
||||
background: var(--card); border:1px solid var(--border); border-radius:8px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,.25); overflow:hidden; max-height:280px;
|
||||
display:flex; flex-direction:column;
|
||||
}
|
||||
.lib-vp-search { padding:8px; border-bottom:1px solid var(--border); }
|
||||
.lib-vp-input { width:100%; box-sizing:border-box; background:var(--panel); border:1px solid var(--border);
|
||||
border-radius:6px; padding:5px 9px; font-size:12px; color:var(--text); outline:none; }
|
||||
.lib-vp-input:focus { border-color:var(--accent); }
|
||||
.lib-vp-list { overflow-y:auto; flex:1; }
|
||||
.lib-vp-item { padding:7px 12px; font-size:12px; cursor:pointer; transition:background .1s; }
|
||||
.lib-vp-item:hover { background:var(--panel); }
|
||||
.lib-vp-item.selected { color:var(--accent); font-weight:600; }
|
||||
|
||||
/* Character production tags */
|
||||
.cl-card-tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:8px; }
|
||||
@ -2563,6 +2924,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
min-height: 32px;
|
||||
}
|
||||
.dc-load-err { color: var(--red) !important; }
|
||||
|
||||
/* ── Engine card skeleton ────────────────────────────────────────────────── */
|
||||
.dc-skel-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 14px 16px; display: flex; flex-direction: column; gap: 10px;
|
||||
min-height: 120px;
|
||||
}
|
||||
.dc-skel-line {
|
||||
border-radius: 5px; height: 13px;
|
||||
background: linear-gradient(90deg, var(--panel), rgba(37,99,235,.08), var(--panel));
|
||||
background-size: 220% 100%; animation: skeletonSweep 1.15s ease-in-out infinite;
|
||||
}
|
||||
.dc-skel-line.wide { width: 70%; }
|
||||
.dc-skel-line.medium { width: 45%; }
|
||||
.dc-skel-line.short { width: 28%; }
|
||||
.dc-skel-line.tall { height: 20px; width: 55%; }
|
||||
.dc-card {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); padding: 14px 16px;
|
||||
@ -3122,6 +3499,7 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
#s-reader.is-active { display: flex; flex-direction: column; height: 100%; min-height: 0; }
|
||||
#s-reader #tab-reader { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
#s-reader #reader-main-view { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
#s-reader #reader-main-view[hidden] { display: none; }
|
||||
#s-reader #reader-doc { flex: 1; min-height: 0; overflow-y: auto; }
|
||||
}
|
||||
.conv-stats-panel { width: 280px; flex-shrink: 0; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
@ -4086,6 +4464,9 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 18px 16px; padding: 6px 4px 4px;
|
||||
}
|
||||
.sk { background: linear-gradient(90deg, var(--panel), rgba(37,99,235,.08), var(--panel)); background-size: 220% 100%; animation: skeletonSweep 1.15s ease-in-out infinite; }
|
||||
.lib-book-sk { background: linear-gradient(135deg, var(--panel), var(--border)) !important; opacity: .7; animation: none; cursor: default; }
|
||||
.lib-book-sk .sk { border-radius: 4px; }
|
||||
.reh-book {
|
||||
position: relative; cursor: pointer; border-radius: 4px 7px 7px 4px;
|
||||
aspect-ratio: 2 / 3; padding: 14px 12px 12px 18px; color: #fff; overflow: hidden;
|
||||
@ -4445,8 +4826,151 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.ab-cv-note { font-size: 11.5px; color: var(--subtext); font-style: italic; padding: 3px 0 3px 4px; border-left: 2px solid var(--border); font-family: sans-serif; }
|
||||
.ab-cv-side { border: 1px solid var(--border); border-radius: 8px; padding: 8px; background: var(--panel); overflow-y: auto; min-height: 0; }
|
||||
.ab-cv-side-head { font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); margin-bottom: 6px; }
|
||||
.ab-cv-chars { display: flex; flex-direction: column; gap: 5px; }
|
||||
.ab-cv-chars { display: flex; flex-direction: column; gap: 2px; }
|
||||
.ab-cv-empty { font-size: 12px; color: var(--subtext); font-style: italic; }
|
||||
/* Skeleton loading placeholders */
|
||||
.ab-skel-feed { padding:8px 0; }
|
||||
.ab-skel-chars { padding:4px 0; }
|
||||
.ab-skel-row, .ab-skel-char-row {
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:6px 10px; border-radius:4px;
|
||||
opacity:.55;
|
||||
}
|
||||
.ab-skel-row { flex-direction:column; align-items:flex-start; gap:5px; margin-bottom:2px; }
|
||||
.ab-skel-row.ab-skel-dlg { flex-direction:row; flex-wrap:wrap; align-items:center; }
|
||||
.ab-skel-line {
|
||||
height:10px; border-radius:4px;
|
||||
background:linear-gradient(90deg, var(--border) 25%, var(--surface) 50%, var(--border) 75%);
|
||||
background-size:200% 100%;
|
||||
animation:skeletonSweep 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ab-skel-narr .ab-skel-line { height:9px; }
|
||||
.ab-skel-dlg .ab-skel-line { height:11px; }
|
||||
.ab-skel-spk {
|
||||
width:52px; height:11px; border-radius:4px; flex-shrink:0;
|
||||
background:linear-gradient(90deg, var(--border) 25%, var(--surface) 50%, var(--border) 75%);
|
||||
background-size:200% 100%;
|
||||
animation:skeletonSweep 1.4s ease-in-out infinite;
|
||||
opacity:.9;
|
||||
}
|
||||
.ab-skel-dot {
|
||||
width:26px; height:26px; border-radius:50%; flex-shrink:0;
|
||||
background:linear-gradient(90deg, var(--border) 25%, var(--surface) 50%, var(--border) 75%);
|
||||
background-size:200% 100%;
|
||||
animation:skeletonSweep 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ab-skel-num {
|
||||
width:18px; height:10px; border-radius:4px; margin-left:auto;
|
||||
background:linear-gradient(90deg, var(--border) 25%, var(--surface) 50%, var(--border) 75%);
|
||||
background-size:200% 100%;
|
||||
animation:skeletonSweep 1.4s ease-in-out infinite;
|
||||
}
|
||||
.ab-skel-char-row .ab-skel-line { height:10px; flex:1; }
|
||||
/* stagger animation delays for a wave effect */
|
||||
.ab-skel-row:nth-child(2) .ab-skel-line,
|
||||
.ab-skel-row:nth-child(2) .ab-skel-spk { animation-delay:.15s; }
|
||||
.ab-skel-row:nth-child(3) .ab-skel-line,
|
||||
.ab-skel-row:nth-child(3) .ab-skel-spk { animation-delay:.3s; }
|
||||
.ab-skel-row:nth-child(4) .ab-skel-line,
|
||||
.ab-skel-row:nth-child(4) .ab-skel-spk { animation-delay:.45s; }
|
||||
.ab-skel-row:nth-child(5) .ab-skel-line { animation-delay:.6s; }
|
||||
.ab-skel-row:nth-child(6) .ab-skel-line { animation-delay:.75s; }
|
||||
.ab-skel-char-row:nth-child(2) * { animation-delay:.1s; }
|
||||
.ab-skel-char-row:nth-child(3) * { animation-delay:.2s; }
|
||||
.ab-skel-char-row:nth-child(4) * { animation-delay:.3s; }
|
||||
.ab-skel-char-row:nth-child(5) * { animation-delay:.4s; }
|
||||
/* Character list rows in the casting side panel */
|
||||
.ab-char-item {
|
||||
display:flex; align-items:center; gap:8px; padding:5px 6px;
|
||||
border-radius:6px; cursor:pointer; transition:background .12s;
|
||||
}
|
||||
.ab-char-item:hover, .ab-char-item.is-active { background:var(--surface); }
|
||||
.ab-char-item.is-active { border-left:2px solid var(--accent); padding-left:4px; }
|
||||
.ab-char-dot {
|
||||
width:26px; height:26px; border-radius:50%; flex-shrink:0;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:11px; font-weight:700; color:#fff;
|
||||
}
|
||||
.ab-char-name { flex:1; font-size:12px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.ab-char-count { font-size:11px; color:var(--subtext); font-weight:700; flex-shrink:0; }
|
||||
/* Character detail panel inside the casting feed area */
|
||||
.ab-char-detail-panel {
|
||||
display:flex; flex-direction:column; overflow-y:auto;
|
||||
height:100%; border:1px solid var(--border); border-radius:8px;
|
||||
background:var(--panel);
|
||||
}
|
||||
.ab-char-detail-back {
|
||||
display:inline-flex; align-items:center; gap:6px;
|
||||
background:none; border:none; cursor:pointer; color:var(--accent);
|
||||
font-size:13px; padding:10px 14px 6px; opacity:.85; flex-shrink:0;
|
||||
}
|
||||
.ab-char-detail-back:hover { opacity:1; }
|
||||
.ab-char-detail-panel .lcd-header { border-radius:0; flex-shrink:0; }
|
||||
.ab-cd-body { padding:0; }
|
||||
.ab-cd-body .lcd-voice-top button {
|
||||
padding:3px 8px; font-size:11px; border:1px solid var(--border);
|
||||
border-radius:4px; background:none; cursor:pointer; color:var(--text);
|
||||
}
|
||||
.ab-cd-edit-btn {
|
||||
position:absolute; top:10px; right:10px;
|
||||
background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3);
|
||||
border-radius:6px; cursor:pointer; color:#fff; padding:5px 8px;
|
||||
font-size:14px; display:flex; align-items:center; line-height:1;
|
||||
transition:background .15s;
|
||||
}
|
||||
.ab-cd-edit-btn:hover { background:rgba(255,255,255,.28); }
|
||||
/* editable fields: show hint border in edit mode */
|
||||
.ab-cd-editing .ab-editable {
|
||||
border-bottom:1px dashed var(--accent);
|
||||
outline:none; cursor:text; min-height:1.3em;
|
||||
border-radius:2px; padding:1px 2px;
|
||||
}
|
||||
.ab-cd-editing .ab-editable:focus { background:rgba(124,106,247,.07); }
|
||||
.ab-cd-body .lcd-voice-top .ab-cd-pick:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.ab-cd-body .lcd-voice-top .ab-cd-auto:hover { border-color:var(--green,#22c55e); color:var(--green,#22c55e); }
|
||||
.ab-cd-body .lcd-voice-top .ab-cd-online:hover { border-color:#0ea5e9; color:#0ea5e9; }
|
||||
/* Character selection bar (compact bar above the casting feed) */
|
||||
.ab-char-bar {
|
||||
display:flex; align-items:center; gap:8px; flex-shrink:0;
|
||||
padding:6px 10px; margin-bottom:6px;
|
||||
background:var(--surface); border:1px solid var(--border); border-radius:8px;
|
||||
}
|
||||
.ab-char-bar-dot {
|
||||
width:32px; height:32px; border-radius:50%; flex-shrink:0;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:13px; font-weight:700; color:#fff;
|
||||
}
|
||||
.ab-char-bar-info { display:flex; flex-direction:column; min-width:0; flex-shrink:0; }
|
||||
.ab-char-bar-name { font-size:13px; font-weight:700; white-space:nowrap; }
|
||||
.ab-char-bar-cnt { font-size:11px; opacity:.5; }
|
||||
.ab-char-bar-nav { display:flex; align-items:center; gap:4px; flex-shrink:0; }
|
||||
.ab-char-bar-nav button {
|
||||
width:26px; height:26px; border-radius:50%;
|
||||
border:1px solid var(--border); background:none; cursor:pointer; color:var(--text);
|
||||
display:flex; align-items:center; justify-content:center; font-size:15px;
|
||||
}
|
||||
.ab-char-bar-nav button:hover { background:var(--panel); border-color:var(--accent); }
|
||||
.ab-char-bar-pos { font-size:11px; opacity:.6; min-width:44px; text-align:center; }
|
||||
.ab-char-bar-search {
|
||||
flex:1; min-width:70px; max-width:160px;
|
||||
padding:4px 8px; font-size:12px;
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
background:var(--bg,var(--panel)); color:var(--text); outline:none;
|
||||
}
|
||||
.ab-char-bar-search:focus { border-color:var(--accent); }
|
||||
.ab-char-bar-profile, .ab-char-bar-close {
|
||||
padding:4px 8px; font-size:12px; flex-shrink:0;
|
||||
border:1px solid var(--border); border-radius:6px;
|
||||
background:none; cursor:pointer; color:var(--text);
|
||||
display:inline-flex; align-items:center; gap:4px;
|
||||
}
|
||||
.ab-char-bar-profile:hover { border-color:var(--accent); color:var(--accent); }
|
||||
.ab-char-bar-close { padding:4px 6px; }
|
||||
.ab-char-bar-close:hover { border-color:#f87171; color:#f87171; }
|
||||
/* Feed row highlights for selected character */
|
||||
.ab-cv-row.ab-char-hl { background:rgba(124,106,247,.08); }
|
||||
.ab-cv-row.ab-char-focus { background:rgba(124,106,247,.20); border-left:3px solid var(--accent); padding-left:5px; }
|
||||
/* Keep old ab-chip for any legacy use */
|
||||
.ab-chip { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; padding: 3px 8px; border: 1px solid var(--border); border-radius: 20px; cursor: pointer; transition: background 0.1s; }
|
||||
.ab-chip:hover { background: var(--panel); border-color: var(--accent); }
|
||||
.ab-chip-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--c); flex-shrink: 0; }
|
||||
@ -4483,11 +5007,65 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
|
||||
.cs-card { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; background: var(--panel); }
|
||||
.cs-card.cs-main { box-shadow: inset 4px 0 0 var(--accent); }
|
||||
.cs-card.cs-supp { box-shadow: inset 4px 0 0 var(--border); }
|
||||
.cs-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
|
||||
.cs-head { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
|
||||
.cs-name { font-weight: 800; font-size: 15px; }
|
||||
.cs-archetype { font-size: 12px; font-weight: 700; color: var(--accent); }
|
||||
.cs-tier { margin-left: auto; font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; }
|
||||
.cs-tier { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; color: var(--subtext); border: 1px solid var(--border); border-radius: 20px; padding: 1px 8px; }
|
||||
.cs-head-btns { margin-left: auto; display: flex; gap: 4px; flex-shrink: 0; }
|
||||
.cs-head-btns .btn-secondary { font-size: 10px; padding: 2px 7px; }
|
||||
.cs-img-btn:hover { border-color: #a78bfa; color: #a78bfa; }
|
||||
.cs-voice-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.cs-aliases { font-size: 12px; color: var(--subtext); font-style: italic; margin: 2px 0 6px; }
|
||||
|
||||
/* Character sheet card layout with avatar */
|
||||
.cs-card-top { display: flex; gap: 14px; margin-bottom: 10px; }
|
||||
.cs-card-top-body { flex: 1; min-width: 0; }
|
||||
.cs-avatar {
|
||||
width: 80px; height: 80px; border-radius: 10px; flex-shrink: 0;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 800; color: #fff;
|
||||
cursor: pointer; position: relative; overflow: hidden;
|
||||
border: 2px solid rgba(255,255,255,.1);
|
||||
}
|
||||
.cs-avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.cs-avatar-overlay {
|
||||
position: absolute; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity .15s; font-size: 20px; color: #fff;
|
||||
}
|
||||
.cs-avatar:hover .cs-avatar-overlay { opacity: 1; }
|
||||
|
||||
/* Library card avatar with image */
|
||||
.lib-char-avatar { position: relative; overflow: hidden; cursor: pointer; }
|
||||
.lib-char-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
|
||||
.lib-char-avatar-cam {
|
||||
position: absolute; inset: 0; background: rgba(0,0,0,.5);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
opacity: 0; transition: opacity .15s; font-size: 14px; color: #fff; border-radius: inherit;
|
||||
}
|
||||
.lib-char-avatar:hover .lib-char-avatar-cam { opacity: 1; }
|
||||
|
||||
/* Alignment gradient bar */
|
||||
.cs-alignment-wrap { margin: 8px 0 6px; }
|
||||
.cs-alignment-labels { display: flex; justify-content: space-between; font-size: 10px; color: var(--subtext); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .04em; }
|
||||
.cs-alignment-gradient {
|
||||
position: relative; height: 20px; border-radius: 4px;
|
||||
background: linear-gradient(to right, #111 0%, #444 25%, #888 50%, #ccc 75%, #f5f5f5 100%);
|
||||
box-shadow: inset 0 1px 3px rgba(0,0,0,.5);
|
||||
}
|
||||
.cs-alignment-dot {
|
||||
position: absolute; top: 50%; transform: translate(-50%, -50%);
|
||||
width: 18px; height: 18px; border-radius: 50%;
|
||||
background: #fff; border: 2px solid rgba(0,0,0,.7);
|
||||
box-shadow: 0 0 0 2px rgba(255,255,255,.5), 0 2px 6px rgba(0,0,0,.6);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.cs-alignment-arc-arrow {
|
||||
font-size: 13px; line-height: 1; font-style: normal; margin-top: -1px;
|
||||
text-shadow: 0 1px 3px rgba(0,0,0,.8);
|
||||
}
|
||||
.cs-arc-note { font-size: 11px; color: var(--subtext); margin-top: 3px; font-style: italic; }
|
||||
.cs-progress-chars { font-size: 11px; color: var(--subtext); text-align: center; min-height: 16px; margin-bottom: 8px; }
|
||||
.cs-fields { margin: 0; display: flex; flex-direction: column; gap: 5px; }
|
||||
.cs-row { display: grid; grid-template-columns: 120px 1fr; gap: 8px; font-size: 12.5px; }
|
||||
.cs-row dt { font-weight: 700; color: var(--subtext); }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user