feat: unified Library + character tags + shared cast (v1.10.0)
Restructure the sidebar into Voices / Speak / Setup groups and fuse the Read Aloud and Script Rehearser features into one workspace: - New combined Library section (Books · Theater Plays · Characters/Cast) with cross-links (Rehearse a book, Read Aloud a play). - Character tags like voice tags: one record, many productions, reusable across books/scripts; seeded with origin book, editable in the editor. - Shared cast resolution: opening a production fills empty cast slots from the character roster (matched by book OR tag); voice choices written back on save. Productions joined by normalized title (prodKey). - Tags nav group for voices (distinct tags + counts, Cloned/Designed/Fav). - Fold standalone Characters into Library; Read Aloud now a single entry. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
464420b11c
commit
52994df70b
14
CHANGELOG.md
14
CHANGELOG.md
@ -9,6 +9,20 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [1.10.0] — 2026-06-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Combined Library** (new *Library* section under *Speak*) with three tabs — **Books**, **Theater Plays**, **Characters / Cast** — bringing audiobooks, rehearsals and the character roster into one place. Book and play cards cross-link: *Rehearse* a book, *Read Aloud* a play.
|
||||||
|
- **Character tags** (like voice tags): each character carries a comma-separated list of productions, seeded with its origin book and editable in the character editor. A character can now be **reused across several books/scripts** — one record, many tags.
|
||||||
|
- **Shared cast resolution**: opening a production in the Rehearser (and audiobook casting, which routes through it) auto-fills empty cast slots from the shared character roster, matched by book **or** tag. Cast voice choices are written back to existing characters on save, keeping the roster in sync. Productions are joined by normalized title (`prodKey`).
|
||||||
|
- **Tags** navigation group under *Voices*: distinct voice tags with live counts, plus the *Cloned / Designed / Favourites* predicates, each filtering the voice list.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Sidebar restructured** into clearer groups: *Voices* (Library · Tags · Voice Actions), *Speak* (Quick Play · Conversation · Read Aloud · Script Rehearsal · Library), *Setup* (Benchmark · Engines · Integrations · Settings). "Try It Out" is now **Quick Play**; "My Voices" is now **Library**.
|
||||||
|
- The standalone **Characters** section was folded into the combined Library; **Read Aloud** is now a single entry (its library lives in the combined Library); the Rehearser's Library/Cast steps moved into the combined Library, leaving Stage · Summary · Import/Export in the sidebar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [1.9.8] — 2026-06-27
|
## [1.9.8] — 2026-06-27
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@ -23,7 +23,7 @@ const MAIN = [
|
|||||||
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
'voice-picker', 'voice-inspector', 'seed-finder', 'voice-sources', 'fishaudio-browser',
|
||||||
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
'integrations', 'routing', 'voice-clone', 'voice-library', 'tts-preview',
|
||||||
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
'benchmark', 'stt', 'rehearser-parse', 'rehearser', 'reader', 'audiobook', 'character-sheets',
|
||||||
'characters-library',
|
'characters-library', 'library',
|
||||||
].map(n => join(jsDir, n + '.js'));
|
].map(n => join(jsDir, n + '.js'));
|
||||||
|
|
||||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||||
|
|||||||
@ -26,7 +26,7 @@
|
|||||||
|
|
||||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||||
<link rel="stylesheet" href="/static/style.css?v=1.9.8">
|
<link rel="stylesheet" href="/static/style.css?v=1.10.0">
|
||||||
|
|
||||||
|
|
||||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||||
@ -119,49 +119,54 @@
|
|||||||
<div class="nav-group-label">Voices</div>
|
<div class="nav-group-label">Voices</div>
|
||||||
<div class="nav-item nav-tree-head active" data-nav-section="s-voices" id="nav-voices-head" onclick="navTo('s-voices')">
|
<div class="nav-item nav-tree-head active" data-nav-section="s-voices" id="nav-voices-head" onclick="navTo('s-voices')">
|
||||||
<span class="nav-icon"><span class="mdi mdi-account-voice"></span></span>
|
<span class="nav-icon"><span class="mdi mdi-account-voice"></span></span>
|
||||||
<span class="nav-label">My Voices</span>
|
<span class="nav-label">Library</span>
|
||||||
<span class="nav-badge" id="nav-voices-count"></span>
|
<span class="nav-badge" id="nav-voices-count"></span>
|
||||||
<span class="nav-chevron" id="nav-voices-chevron" onclick="event.stopPropagation();toggleNavTree('nav-voices-tree','nav-voices-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
<span class="nav-chevron" id="nav-voices-chevron" onclick="event.stopPropagation();toggleNavTree('nav-voices-tree','nav-voices-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-tree open" id="nav-voices-tree">
|
<div class="nav-tree open" id="nav-voices-tree">
|
||||||
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')"><span class="mdi mdi-account-voice"></span> All voices <span class="ntc" id="ntc-all"></span></div>
|
<div class="nav-tree-item is-active" data-voice-cat="all" onclick="navVoicesCat('all')"><span class="mdi mdi-account-voice"></span> All voices <span class="ntc" id="ntc-all"></span></div>
|
||||||
<div class="nav-tree-item" data-voice-cat="cloned" onclick="navVoicesCat('cloned')"><span class="mdi mdi-microphone-variant"></span> Cloned <span class="ntc" id="ntc-cloned"></span></div>
|
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')"><span class="mdi mdi-star-outline"></span> Favourites <span class="ntc" id="ntc-favorites"></span></div>
|
||||||
<div class="nav-tree-item" data-voice-cat="designed" onclick="navVoicesCat('designed')"><span class="mdi mdi-auto-fix"></span> Designed <span class="ntc" id="ntc-designed"></span></div>
|
|
||||||
<div class="nav-tree-item" data-voice-cat="favorites" onclick="navVoicesCat('favorites')"><span class="mdi mdi-star-outline"></span> Favorites <span class="ntc" id="ntc-favorites"></span></div>
|
|
||||||
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')"><span class="mdi mdi-eye-off-outline"></span> Hidden <span class="ntc" id="ntc-hidden"></span></div>
|
<div class="nav-tree-item" data-voice-cat="hidden" onclick="navVoicesCat('hidden')"><span class="mdi mdi-eye-off-outline"></span> Hidden <span class="ntc" id="ntc-hidden"></span></div>
|
||||||
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')"><span class="mdi mdi-wrench-outline"></span> Library tools</div>
|
<div class="nav-tree-item" data-voice-cat="tools" onclick="navVoicesCat('tools')"><span class="mdi mdi-wrench-outline"></span> Tools</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-item nav-tree-head" data-nav-section="s-voices" id="nav-tags-head" onclick="navTo('s-voices')">
|
||||||
|
<span class="nav-icon"><span class="mdi mdi-tag-multiple-outline"></span></span>
|
||||||
|
<span class="nav-label">Tags</span>
|
||||||
|
<span class="nav-chevron" id="nav-tags-chevron" onclick="event.stopPropagation();toggleNavTree('nav-tags-tree','nav-tags-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
||||||
|
</div>
|
||||||
|
<div class="nav-tree" id="nav-tags-tree"></div>
|
||||||
|
<div class="nav-group-label">Voice Actions</div>
|
||||||
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div>
|
<div class="nav-item" data-nav-section="s-clone" onclick="navTo('s-clone')"> <span class="nav-icon"><span class="mdi mdi-microphone-variant"></span></span> Clone a Voice</div>
|
||||||
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
|
<div class="nav-item" data-nav-section="s-design" onclick="navTo('s-design')"> <span class="nav-icon"><span class="mdi mdi-auto-fix"></span></span> Design a Voice</div>
|
||||||
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get Voices Online</div>
|
<div class="nav-item" data-nav-section="s-studio" onclick="navTo('s-studio')"> <span class="nav-icon"><span class="mdi mdi-earth"></span></span> Get a Voice Online</div>
|
||||||
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Try It Out</div>
|
|
||||||
|
<div class="nav-group-label">Speak</div>
|
||||||
|
<div class="nav-item" data-nav-section="s-tryout" onclick="navTo('s-tryout')"> <span class="nav-icon"><span class="mdi mdi-play"></span></span> Quick Play</div>
|
||||||
|
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
||||||
|
<div class="nav-item" data-nav-section="s-reader" onclick="navTo('s-reader')"> <span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span> Read Aloud</div>
|
||||||
<div class="nav-item nav-tree-head" data-nav-section="s-rehearser" id="nav-rehearser-head" onclick="navTo('s-rehearser')">
|
<div class="nav-item nav-tree-head" 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-icon"><span class="mdi mdi-theater"></span></span>
|
||||||
<span class="nav-label">Script Rehearser</span>
|
<span class="nav-label">Script Rehearsal</span>
|
||||||
<span class="nav-chevron" id="nav-rehearser-chevron" onclick="event.stopPropagation();toggleNavTree('nav-rehearser-tree','nav-rehearser-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
<span class="nav-chevron" id="nav-rehearser-chevron" onclick="event.stopPropagation();toggleNavTree('nav-rehearser-tree','nav-rehearser-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-tree" id="nav-rehearser-tree">
|
<div class="nav-tree" id="nav-rehearser-tree">
|
||||||
<div class="nav-tree-item is-active" data-rehearser-phase="1" onclick="navRehearserPhase(1)"><span class="mdi mdi-bookshelf"></span> Library</div>
|
<div class="nav-tree-item is-active" data-rehearser-phase="3" onclick="navRehearserPhase(3)"><span class="mdi mdi-theater"></span> Stage</div>
|
||||||
<div class="nav-tree-item" data-rehearser-phase="2" onclick="navRehearserPhase(2)"><span class="mdi mdi-account-group-outline"></span> Cast</div>
|
|
||||||
<div class="nav-tree-item" data-rehearser-phase="3" onclick="navRehearserPhase(3)"><span class="mdi mdi-theater"></span> Stage</div>
|
|
||||||
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
|
<div class="nav-tree-item" data-rehearser-phase="4" onclick="navRehearserPhase(4)"><span class="mdi mdi-check-circle-outline"></span> Summary</div>
|
||||||
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
|
<div class="nav-tree-item" id="nav-reh-impex" onclick="navRehearserImpEx()"><span class="mdi mdi-swap-vertical"></span> Import / Export</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item nav-tree-head" data-nav-section="s-reader" id="nav-reader-head" onclick="navTo('s-reader')">
|
<div class="nav-item nav-tree-head" data-nav-section="s-library" id="nav-library-head" onclick="navTo('s-library')">
|
||||||
<span class="nav-icon"><span class="mdi mdi-book-open-page-variant-outline"></span></span>
|
<span class="nav-icon"><span class="mdi mdi-bookshelf"></span></span>
|
||||||
<span class="nav-label">Read Aloud</span>
|
<span class="nav-label">Library</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>
|
<span class="nav-chevron" id="nav-library-chevron" onclick="event.stopPropagation();toggleNavTree('nav-library-tree','nav-library-chevron')"><span class="mdi mdi-chevron-down"></span></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-tree" id="nav-reader-tree">
|
<div class="nav-tree" id="nav-library-tree">
|
||||||
<div class="nav-tree-item is-active" data-reader-view="main" onclick="navReaderView('main')"><span class="mdi mdi-book-open-outline"></span> Reader</div>
|
<div class="nav-tree-item is-active" data-library-view="books" onclick="navLibraryView('books')"><span class="mdi mdi-book-open-page-variant-outline"></span> Books</div>
|
||||||
<div class="nav-tree-item" data-reader-view="cast" onclick="navReaderView('cast')"><span class="mdi mdi-drama-masks"></span> Casting audiobook</div>
|
<div class="nav-tree-item" data-library-view="plays" onclick="navLibraryView('plays')"><span class="mdi mdi-theater"></span> Theater Plays</div>
|
||||||
<div class="nav-tree-item" data-reader-view="library" onclick="navReaderView('library')"><span class="mdi mdi-bookshelf"></span> Library</div>
|
<div class="nav-tree-item" data-library-view="characters" onclick="navLibraryView('characters')"><span class="mdi mdi-account-box-multiple-outline"></span> Characters / Cast</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-item" data-nav-section="s-characters" onclick="navTo('s-characters')"> <span class="nav-icon"><span class="mdi mdi-account-box-multiple-outline"></span></span> Characters</div>
|
|
||||||
<div class="nav-item" data-nav-section="s-conversation" onclick="navTo('s-conversation')"> <span class="nav-icon"><span class="mdi mdi-forum-outline"></span></span> Conversation</div>
|
|
||||||
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
|
||||||
|
|
||||||
<div class="nav-group-label" style="margin-top:6px">Setup</div>
|
<div class="nav-group-label" style="margin-top:6px">Setup</div>
|
||||||
|
<div class="nav-item" data-nav-section="s-performance" onclick="navTo('s-performance')"> <span class="nav-icon"><span class="mdi mdi-speedometer"></span></span> Benchmark</div>
|
||||||
<div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
|
<div class="nav-item nav-tree-head" data-nav-section="s-llms" id="nav-engines-head" onclick="navTo('s-llms')">
|
||||||
<span class="nav-icon"><span class="mdi mdi-brain"></span></span>
|
<span class="nav-icon"><span class="mdi mdi-brain"></span></span>
|
||||||
<span class="nav-label">Engines</span>
|
<span class="nav-label">Engines</span>
|
||||||
@ -172,6 +177,7 @@
|
|||||||
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
|
<div class="nav-tree-item" data-engines-cat="stt" onclick="navEnginesCat('stt')"><span class="mdi mdi-ear-hearing"></span> Speech to Text</div>
|
||||||
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-account-voice"></span> Text to Speech</div>
|
<div class="nav-tree-item" data-engines-cat="tts" onclick="navEnginesCat('tts')"><span class="mdi mdi-account-voice"></span> Text to Speech</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="nav-group-label">Integrations</div>
|
||||||
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')"> <span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing</div>
|
<div class="nav-item" data-nav-section="s-routing" onclick="navTo('s-routing')"> <span class="nav-icon"><span class="mdi mdi-swap-horizontal"></span></span> App Routing</div>
|
||||||
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')"> <span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps</div>
|
<div class="nav-item" data-nav-section="s-connect" onclick="navTo('s-connect')"> <span class="nav-icon"><span class="mdi mdi-api"></span></span> Connect Apps</div>
|
||||||
|
|
||||||
@ -272,8 +278,8 @@
|
|||||||
<section class="page-section" id="s-llms" style="display:none"></section>
|
<section class="page-section" id="s-llms" style="display:none"></section>
|
||||||
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
<section class="page-section" id="s-rehearser" style="display:none"></section>
|
||||||
<section class="page-section" id="s-reader" style="display:none"></section>
|
<section class="page-section" id="s-reader" style="display:none"></section>
|
||||||
<section class="page-section" id="s-characters" style="display:none"></section>
|
|
||||||
<section class="page-section" id="s-conversation" style="display:none"></section>
|
<section class="page-section" id="s-conversation" style="display:none"></section>
|
||||||
|
<section class="page-section" id="s-library" style="display:none"></section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
</div><!-- /app-shell -->
|
</div><!-- /app-shell -->
|
||||||
@ -308,7 +314,7 @@
|
|||||||
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
<script src="/static/vendor/wavesurfer-regions.min.js"></script>
|
||||||
|
|
||||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||||
<script src="/static/loader.js?v=1.9.8"></script>
|
<script src="/static/loader.js?v=1.10.0"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@ -97,11 +97,15 @@ async function clUpsert(book, sheet) {
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const prev = await clGet(id).catch(() => null);
|
const prev = await clGet(id).catch(() => null);
|
||||||
const merged = prev ? clMergeSheet(prev.sheet || {}, sheet) : { ...sheet, name };
|
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 tags = clMergeTags(prev?.tags, sheet.tags, bk);
|
||||||
const rec = {
|
const rec = {
|
||||||
id, book: bk, name,
|
id, book: bk, name, tags,
|
||||||
sheet: merged,
|
sheet: merged,
|
||||||
analysis: prev?.analysis || null,
|
analysis: prev?.analysis || null,
|
||||||
voice: prev?.voice || null,
|
voice: prev?.voice || sheet.voice || null,
|
||||||
created: prev?.created || now,
|
created: prev?.created || now,
|
||||||
updated: now,
|
updated: now,
|
||||||
};
|
};
|
||||||
@ -109,6 +113,24 @@ async function clUpsert(book, sheet) {
|
|||||||
return 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(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// All characters belonging to a production, by origin book OR by tag membership.
|
||||||
|
async function clGetAllByTagOrBook(title) {
|
||||||
|
const key = String(title || '').trim().toLowerCase();
|
||||||
|
if (!key) return [];
|
||||||
|
const all = await clGetAll().catch(() => []);
|
||||||
|
return all.filter(r => {
|
||||||
|
if (String(r.book || '').trim().toLowerCase() === key) return true;
|
||||||
|
return String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
|
// Bulk upsert a list of sheets for one book (used by the analysis side-effect).
|
||||||
async function clUpsertMany(book, sheets) {
|
async function clUpsertMany(book, sheets) {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
@ -130,18 +152,29 @@ async function clRender() {
|
|||||||
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
|
if (searchEl && !searchEl.dataset.bound) { searchEl.dataset.bound = '1'; searchEl.addEventListener('input', clApplyFilter); }
|
||||||
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
|
try { _clRecords = await clGetAll(); } catch (e) { _clRecords = []; }
|
||||||
|
|
||||||
// Populate the book filter (preserve current selection if still present).
|
// 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 filter = document.getElementById('cl-book-filter');
|
||||||
const books = [...new Set(_clRecords.map(r => r.book))].sort((a, b) => a.localeCompare(b));
|
const prods = clAllProductions();
|
||||||
if (filter) {
|
if (filter) {
|
||||||
const cur = filter.value;
|
const cur = filter.value;
|
||||||
filter.innerHTML = `<option value="">All books (${books.length})</option>` +
|
filter.innerHTML = `<option value="">All productions (${prods.length})</option>` +
|
||||||
books.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
|
prods.map(b => `<option value="${escHtml(b)}">${escHtml(b)}</option>`).join('');
|
||||||
if (cur && books.includes(cur)) filter.value = cur;
|
if (cur && prods.includes(cur)) filter.value = cur;
|
||||||
}
|
}
|
||||||
clApplyFilter();
|
clApplyFilter();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Distinct production names across origin books and character tags.
|
||||||
|
function clAllProductions() {
|
||||||
|
const set = new Set();
|
||||||
|
_clRecords.forEach(r => {
|
||||||
|
if (r.book) set.add(r.book);
|
||||||
|
String(r.tags || '').split(',').map(t => t.trim()).filter(Boolean).forEach(t => set.add(t));
|
||||||
|
});
|
||||||
|
return [...set].sort((a, b) => a.localeCompare(b));
|
||||||
|
}
|
||||||
|
|
||||||
function clApplyFilter() {
|
function clApplyFilter() {
|
||||||
const grid = document.getElementById('cl-grid');
|
const grid = document.getElementById('cl-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
@ -149,11 +182,17 @@ function clApplyFilter() {
|
|||||||
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
|
const q = (document.getElementById('cl-search')?.value || '').trim().toLowerCase();
|
||||||
|
|
||||||
let recs = _clRecords.slice();
|
let recs = _clRecords.slice();
|
||||||
if (book) recs = recs.filter(r => r.book === book);
|
if (book) {
|
||||||
|
const bk = book.toLowerCase();
|
||||||
|
recs = recs.filter(r =>
|
||||||
|
(r.book || '').toLowerCase() === bk ||
|
||||||
|
String(r.tags || '').split(',').some(t => t.trim().toLowerCase() === bk));
|
||||||
|
}
|
||||||
if (q) recs = recs.filter(r =>
|
if (q) recs = recs.filter(r =>
|
||||||
(r.name || '').toLowerCase().includes(q) ||
|
(r.name || '').toLowerCase().includes(q) ||
|
||||||
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
|
(r.sheet?.aliases || '').toLowerCase().includes(q) ||
|
||||||
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
|
(r.sheet?.archetype || '').toLowerCase().includes(q) ||
|
||||||
|
(r.tags || '').toLowerCase().includes(q) ||
|
||||||
(r.book || '').toLowerCase().includes(q));
|
(r.book || '').toLowerCase().includes(q));
|
||||||
|
|
||||||
if (!recs.length) {
|
if (!recs.length) {
|
||||||
@ -184,12 +223,17 @@ function clApplyFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clCardHtml(rec) {
|
function clCardHtml(rec) {
|
||||||
|
const tags = String(rec.tags || '').split(',').map(t => t.trim()).filter(Boolean);
|
||||||
|
const chips = tags.length
|
||||||
|
? `<div class="cl-card-tags">${tags.map(t => `<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>${escHtml(t)}</span>`).join('')}</div>`
|
||||||
|
: '';
|
||||||
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
|
return `<div class="cl-card-wrap" data-id="${escHtml(rec.id)}">
|
||||||
<div class="cl-card-tools">
|
<div class="cl-card-tools">
|
||||||
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
|
<button class="btn-secondary btn-sm cl-edit" data-id="${escHtml(rec.id)}" title="Edit this character"><span class="mdi mdi-pencil-outline"></span> Edit</button>
|
||||||
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
|
<button class="btn-secondary btn-sm cl-delete" data-id="${escHtml(rec.id)}" title="Delete from library"><span class="mdi mdi-trash-can-outline"></span></button>
|
||||||
</div>
|
</div>
|
||||||
${csCardHtml(rec.sheet)}
|
${csCardHtml(rec.sheet)}
|
||||||
|
${chips}
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,6 +262,8 @@ function clEdit(id) {
|
|||||||
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
<button class="btn-primary btn-sm" id="cl-edit-save"><span class="mdi mdi-content-save-outline"></span> Save</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="cl-edit-body">
|
<div class="cl-edit-body">
|
||||||
|
<label class="cl-edit-row"><span>Tags / productions (comma-separated — reuse across books & scripts)</span>
|
||||||
|
<input type="text" data-rec-field="tags" value="${escHtml(String(rec.tags || rec.book || ''))}" placeholder="e.g. Das Jahr des Greifen, Star-Trek"></label>
|
||||||
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
|
<label class="cl-edit-row"><span>Moral alignment (0 evil – 100 good)</span>
|
||||||
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
|
<input type="number" min="0" max="100" data-field="moral_alignment_score" value="${s.moral_alignment_score ?? 50}"></label>
|
||||||
<label class="cl-edit-row"><span>Arc direction</span>
|
<label class="cl-edit-row"><span>Arc direction</span>
|
||||||
@ -239,9 +285,12 @@ function clEdit(id) {
|
|||||||
let mas = parseInt(patch.moral_alignment_score, 10);
|
let mas = parseInt(patch.moral_alignment_score, 10);
|
||||||
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
|
mas = isNaN(mas) ? 50 : Math.max(0, Math.min(100, mas));
|
||||||
const newSheet = { ...s, ...patch, name: newName, moral_alignment_score: 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.
|
// If the name changed, the key changes too — delete old, write new.
|
||||||
const newId = clKey(rec.book, newName);
|
const newId = clKey(rec.book, newName);
|
||||||
rec.sheet = newSheet; rec.name = newName; rec.updated = new Date();
|
rec.sheet = newSheet; rec.name = newName; rec.tags = newTags; rec.updated = new Date();
|
||||||
try {
|
try {
|
||||||
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
|
if (newId !== rec.id) { await clDelete(rec.id); rec.id = newId; }
|
||||||
await clPut(rec);
|
await clPut(rec);
|
||||||
@ -279,3 +328,7 @@ document.addEventListener('click', e => {
|
|||||||
|
|
||||||
window.clRender = clRender;
|
window.clRender = clRender;
|
||||||
window.clUpsertMany = clUpsertMany;
|
window.clUpsertMany = clUpsertMany;
|
||||||
|
window.clUpsert = clUpsert;
|
||||||
|
window.clGetAll = clGetAll;
|
||||||
|
window.clPut = clPut;
|
||||||
|
window.clGetAllByTagOrBook = clGetAllByTagOrBook;
|
||||||
|
|||||||
299
static/js/library.js
Normal file
299
static/js/library.js
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
// ── Combined Library — Books · Theater Plays · Characters/Cast ────────────────
|
||||||
|
//
|
||||||
|
// One home for every production. Books live server-side (reader_library),
|
||||||
|
// plays live client-side (IndexedDB reh-library), characters live client-side
|
||||||
|
// (IndexedDB character-library). They are joined by their normalized title —
|
||||||
|
// see prodKey() — so a book and a play with the same title are one production
|
||||||
|
// and share the character cast. This module owns the s-library section and
|
||||||
|
// provides the cross-links that open a production in Read Aloud or the Rehearser.
|
||||||
|
|
||||||
|
const LIB_READER_API = '/api/reader/docs';
|
||||||
|
|
||||||
|
// The universal join key across books, plays and characters (mirrors clKey).
|
||||||
|
function prodKey(title) { return String(title || '').trim().toLowerCase(); }
|
||||||
|
|
||||||
|
window._libraryView = (function () {
|
||||||
|
try { return localStorage.getItem('ttsvc_library_view') || 'books'; } catch (_) { return 'books'; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
window.navLibraryView = function (view) {
|
||||||
|
if (typeof navTo === 'function') navTo('s-library');
|
||||||
|
window._libraryView = view;
|
||||||
|
try { localStorage.setItem('ttsvc_library_view', view); } catch (_) {}
|
||||||
|
document.querySelectorAll('[data-library-view]').forEach(function (el) {
|
||||||
|
el.classList.toggle('is-active', el.dataset.libraryView === view);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-library-panel]').forEach(function (el) {
|
||||||
|
el.classList.toggle('is-active', el.dataset.libraryPanel === view);
|
||||||
|
});
|
||||||
|
libraryRender(view);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.libraryRender = function (view) {
|
||||||
|
view = view || window._libraryView || 'books';
|
||||||
|
// Reflect the active tab/panel even when entered via the section hook
|
||||||
|
document.querySelectorAll('[data-library-view]').forEach(function (el) {
|
||||||
|
el.classList.toggle('is-active', el.dataset.libraryView === view);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('[data-library-panel]').forEach(function (el) {
|
||||||
|
el.classList.toggle('is-active', el.dataset.libraryPanel === view);
|
||||||
|
});
|
||||||
|
if (view === 'books') libraryRenderBooks();
|
||||||
|
else if (view === 'plays') libraryRenderPlays();
|
||||||
|
else if (view === 'characters' && typeof clRender === 'function') clRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Books (server-side reader library) ────────────────────────────────────────
|
||||||
|
|
||||||
|
async function libraryRenderBooks() {
|
||||||
|
const list = document.getElementById('lib-books-list');
|
||||||
|
if (!list) return;
|
||||||
|
let all = [];
|
||||||
|
try { const r = await fetch(LIB_READER_API); if (r.ok) all = (await r.json()).docs || []; } catch (_) { all = []; }
|
||||||
|
if (!all.length) {
|
||||||
|
list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-book-open-outline"></span><p>No books yet.</p>'
|
||||||
|
+ '<p class="lib-empty-hint">Open <b>Read Aloud</b>, import a PDF or text, and save it to your library.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); });
|
||||||
|
list.innerHTML = all.map(function (rec) {
|
||||||
|
const total = rec.sentenceCount || 0;
|
||||||
|
const synthPct = total ? Math.round(((rec.synthCount || 0) / total) * 100) : 0;
|
||||||
|
const readPct = total ? Math.round(((rec.idx || 0) / total) * 100) : 0;
|
||||||
|
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '';
|
||||||
|
const cov = libBookCover(rec.title || 'Untitled');
|
||||||
|
const coverUrl = LIB_READER_API + '/' + rec.id + '/cover?t=' + new Date(rec.updated || Date.now()).getTime();
|
||||||
|
const bg = rec.hasCover
|
||||||
|
? 'style="background-image:linear-gradient(to bottom,rgba(0,0,0,.3),rgba(0,0,0,.8)),url(\'' + coverUrl + '\');background-size:cover;background-position:center;color:#fff"'
|
||||||
|
: 'style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '"';
|
||||||
|
return '<div class="reh-book lib-book" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" ' + bg + ' title="' + escHtml(rec.title || '') + '">'
|
||||||
|
+ '<div class="reh-book-actions">'
|
||||||
|
+ '<button class="reh-book-act lib-act-rehearse" title="Rehearse this title"><span class="mdi mdi-theater"></span></button>'
|
||||||
|
+ '<button class="reh-book-act lib-act-del-book" title="Delete book"><span class="mdi mdi-delete-outline"></span></button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
|
||||||
|
+ '<div class="reh-book-meta">'
|
||||||
|
+ total + ' sentences' + (rec.pageCount ? ' · ' + rec.pageCount + ' pg' : '')
|
||||||
|
+ '<div class="reh-book-progress" title="' + synthPct + '% audio"><div style="width:' + synthPct + '%"></div></div>'
|
||||||
|
+ '<div style="margin-top:4px;opacity:.8"><span class="mdi ' + (rec.kind === 'pdf' ? 'mdi-file-pdf-box' : 'mdi-text-box-outline') + '"></span> ' + readPct + '% read · ' + date + '</div>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.lib-book').forEach(function (el) {
|
||||||
|
const id = el.dataset.id, title = el.dataset.title;
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.reh-book-act')) return;
|
||||||
|
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(id);
|
||||||
|
});
|
||||||
|
const reh = el.querySelector('.lib-act-rehearse');
|
||||||
|
if (reh) reh.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInRehearser(title); });
|
||||||
|
const del = el.querySelector('.lib-act-del-book');
|
||||||
|
if (del) del.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
libConfirmDelete(el, 'Delete book?', 'Audio files will be removed.', async function () {
|
||||||
|
try { const r = await fetch(LIB_READER_API + '/' + id, { method: 'DELETE' }); if (!r.ok) throw new Error('delete failed'); toast('Book deleted', 'success'); libraryRenderBooks(); }
|
||||||
|
catch (err) { toast(err.message || 'Delete failed', 'error'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Theater Plays (client-side rehearser library) ─────────────────────────────
|
||||||
|
|
||||||
|
async function libraryRenderPlays() {
|
||||||
|
const list = document.getElementById('lib-plays-list');
|
||||||
|
if (!list) return;
|
||||||
|
let all = [];
|
||||||
|
try { all = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : []; } catch (_) { all = []; }
|
||||||
|
if (!all.length) {
|
||||||
|
list.innerHTML = '<div class="lib-empty"><span class="mdi mdi-theater"></span><p>No theater plays yet.</p>'
|
||||||
|
+ '<p class="lib-empty-hint">Open <b>Script Rehearsal</b> → Import / Export to add a script, or cast a book as an audiobook.</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
all.sort(function (a, b) { return new Date(b.updated || 0) - new Date(a.updated || 0); });
|
||||||
|
list.innerHTML = all.map(function (rec) {
|
||||||
|
const speakers = Object.keys(rec.cast || {});
|
||||||
|
const total = (typeof parseScript === 'function') ? parseScript(rec.script || '').filter(function (l) { return l.type === 'dialog'; }).length : 0;
|
||||||
|
const pct = total ? Math.round(((rec.lineIndex || 0) / total) * 100) : 0;
|
||||||
|
const date = rec.updated ? new Date(rec.updated).toLocaleDateString() : '—';
|
||||||
|
const cov = libBookCover(rec.title || 'Untitled');
|
||||||
|
const avatars = speakers.slice(0, 5).map(function (sp) {
|
||||||
|
const c = rec.cast[sp] || {};
|
||||||
|
return '<span style="background:' + (c.color || '#888') + '">' + (sp[0] || '?').toUpperCase() + '</span>';
|
||||||
|
}).join('');
|
||||||
|
return '<div class="reh-book lib-play" data-id="' + rec.id + '" data-title="' + escHtml(rec.title || '') + '" style="--bk1:' + cov.c1 + ';--bk2:' + cov.c2 + '" title="' + escHtml(rec.title || '') + '">'
|
||||||
|
+ '<div class="reh-book-actions">'
|
||||||
|
+ '<button class="reh-book-act lib-act-readaloud" title="Read aloud"><span class="mdi mdi-book-open-page-variant-outline"></span></button>'
|
||||||
|
+ '<button class="reh-book-act lib-act-del-play" title="Delete rehearsal"><span class="mdi mdi-delete-outline"></span></button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="reh-book-title">' + escHtml(rec.title || 'Untitled') + '</div>'
|
||||||
|
+ '<div class="reh-book-meta">'
|
||||||
|
+ '<div class="reh-book-avatars">' + avatars + '</div>'
|
||||||
|
+ total + ' lines · ' + speakers.length + ' cast'
|
||||||
|
+ '<div class="reh-book-progress"><div style="width:' + pct + '%"></div></div>'
|
||||||
|
+ '<div style="margin-top:4px;opacity:.8">' + pct + '% · ' + date + '</div>'
|
||||||
|
+ '</div></div>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.lib-play').forEach(function (el) {
|
||||||
|
const id = parseInt(el.dataset.id, 10), title = el.dataset.title;
|
||||||
|
el.addEventListener('click', function (e) {
|
||||||
|
if (e.target.closest('.reh-book-act')) return;
|
||||||
|
openPlayInRehearser(id);
|
||||||
|
});
|
||||||
|
const ra = el.querySelector('.lib-act-readaloud');
|
||||||
|
if (ra) ra.addEventListener('click', function (e) { e.stopPropagation(); productionOpenInReader(title); });
|
||||||
|
const del = el.querySelector('.lib-act-del-play');
|
||||||
|
if (del) del.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
libConfirmDelete(el, 'Delete rehearsal?', 'This cannot be undone.', async function () {
|
||||||
|
try { if (typeof rehDbDelete === 'function') await rehDbDelete(id); toast('Rehearsal deleted', 'success'); libraryRenderPlays(); }
|
||||||
|
catch (err) { toast(err.message || 'Delete failed', 'error'); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cross-links between Read Aloud and the Rehearser ──────────────────────────
|
||||||
|
|
||||||
|
async function openPlayInRehearser(id) {
|
||||||
|
try {
|
||||||
|
const db = await rehDbOpen();
|
||||||
|
const rec = await new Promise(function (res, rej) {
|
||||||
|
const r = db.transaction(REH_STORE, 'readonly').objectStore(REH_STORE).get(id);
|
||||||
|
r.onsuccess = function (e) { res(e.target.result); }; r.onerror = function (e) { rej(e.target.error); };
|
||||||
|
});
|
||||||
|
if (rec && typeof loadRecord === 'function') loadRecord(rec);
|
||||||
|
} catch (err) { toast('Could not open rehearsal', 'error'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a production in the Rehearser: reuse an existing play of the same title,
|
||||||
|
// else seed one from the book's source text.
|
||||||
|
async function productionOpenInRehearser(title) {
|
||||||
|
const key = prodKey(title);
|
||||||
|
try {
|
||||||
|
const plays = (typeof rehDbGetAll === 'function') ? await rehDbGetAll() : [];
|
||||||
|
const match = plays.find(function (p) { return prodKey(p.title) === key; });
|
||||||
|
if (match) { openPlayInRehearser(match.id); return; }
|
||||||
|
} catch (_) {}
|
||||||
|
// No play yet — try to seed from the matching book's source.
|
||||||
|
let books = [];
|
||||||
|
try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {}
|
||||||
|
const book = books.find(function (b) { return prodKey(b.title) === key; });
|
||||||
|
if (book && book.kind !== 'pdf') {
|
||||||
|
try {
|
||||||
|
const sr = await fetch(LIB_READER_API + '/' + book.id + '/source');
|
||||||
|
const text = sr.ok ? await sr.text() : '';
|
||||||
|
if (text && typeof audiobookOpenInRehearser === 'function') { audiobookOpenInRehearser(text, title, []); return; }
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
if (book && book.kind === 'pdf') {
|
||||||
|
if (typeof readerOpenLibraryDoc === 'function') readerOpenLibraryDoc(book.id);
|
||||||
|
toast('Open this PDF book, then use "Cast as audiobook" to build a rehearsal', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('No source to rehearse for this title yet', 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a production in Read Aloud: reuse the matching saved book.
|
||||||
|
async function productionOpenInReader(title) {
|
||||||
|
const key = prodKey(title);
|
||||||
|
let books = [];
|
||||||
|
try { const r = await fetch(LIB_READER_API); if (r.ok) books = (await r.json()).docs || []; } catch (_) {}
|
||||||
|
const book = books.find(function (b) { return prodKey(b.title) === key; });
|
||||||
|
if (book && typeof readerOpenLibraryDoc === 'function') { readerOpenLibraryDoc(book.id); return; }
|
||||||
|
if (typeof navTo === 'function') navTo('s-reader');
|
||||||
|
toast('No audiobook for this title yet — import its source in Read Aloud', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared cast — the character roster a production resolves against ──────────
|
||||||
|
//
|
||||||
|
// Returns name(lowercased) → { name, voice, gender, soul, tags } gathered from
|
||||||
|
// the character library by origin book OR tag membership. The rehearser and the
|
||||||
|
// audiobook caster both fill empty speaker slots from this, so a character cast
|
||||||
|
// once is reused everywhere the same production is opened.
|
||||||
|
async function castForProduction(title) {
|
||||||
|
const out = {};
|
||||||
|
if (typeof clGetAllByTagOrBook !== 'function') return out;
|
||||||
|
let recs = [];
|
||||||
|
try { recs = await clGetAllByTagOrBook(title); } catch (_) { recs = []; }
|
||||||
|
recs.forEach(function (r) {
|
||||||
|
const name = (r.name || '').trim();
|
||||||
|
if (!name) return;
|
||||||
|
const voice = (r.voice && r.voice.id) ? r.voice.id : (typeof r.voice === 'string' ? r.voice : '');
|
||||||
|
out[name.toLowerCase()] = {
|
||||||
|
name: name,
|
||||||
|
voice: voice || '',
|
||||||
|
gender: (r.sheet && r.sheet.gender) || '',
|
||||||
|
soul: (r.sheet && (r.sheet.voice_pattern || r.sheet.motivation)) || '',
|
||||||
|
tags: r.tags || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
window.castForProduction = castForProduction;
|
||||||
|
|
||||||
|
// Persist cast voice choices back to the shared roster. Guard: only updates
|
||||||
|
// characters that ALREADY exist for this production (by name) — never creates
|
||||||
|
// records, so throwaway scripts and narrators don't pollute the library.
|
||||||
|
async function castWriteBack(title, castMap) {
|
||||||
|
if (!title || !castMap || typeof clGetAllByTagOrBook !== 'function' || typeof clPut !== 'function') return;
|
||||||
|
let recs = [];
|
||||||
|
try { recs = await clGetAllByTagOrBook(title); } catch (_) { return; }
|
||||||
|
if (!recs.length) return;
|
||||||
|
const byName = {};
|
||||||
|
recs.forEach(function (r) { byName[(r.name || '').trim().toLowerCase()] = r; });
|
||||||
|
let n = 0;
|
||||||
|
for (const sp of Object.keys(castMap)) {
|
||||||
|
if (String(sp).includes('NARRATOR')) continue;
|
||||||
|
const slot = castMap[sp] || {};
|
||||||
|
const voice = slot.voice;
|
||||||
|
if (!voice || voice === 'me') continue;
|
||||||
|
const rec = byName[String(sp).trim().toLowerCase()];
|
||||||
|
if (!rec) continue;
|
||||||
|
const curId = (rec.voice && rec.voice.id) ? rec.voice.id : (typeof rec.voice === 'string' ? rec.voice : '');
|
||||||
|
if (curId === voice) continue;
|
||||||
|
rec.voice = { id: voice };
|
||||||
|
rec.updated = new Date();
|
||||||
|
try { await clPut(rec); n++; } catch (_) {}
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
window.castWriteBack = castWriteBack;
|
||||||
|
|
||||||
|
// ── Shared helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// Deterministic two-tone cover from a title (mirrors bookCover in rehearser.js,
|
||||||
|
// kept local so the Library renders even if that module loads later).
|
||||||
|
function libBookCover(title) {
|
||||||
|
let h = 0; const s = String(title || 'Untitled');
|
||||||
|
for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) % 360;
|
||||||
|
return { c1: 'hsl(' + h + ',55%,42%)', c2: 'hsl(' + ((h + 40) % 360) + ',58%,30%)' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function libConfirmDelete(cardEl, heading, sub, onConfirm) {
|
||||||
|
if (cardEl.querySelector('.reh-book-del-confirm')) return;
|
||||||
|
const o = document.createElement('div');
|
||||||
|
o.className = 'reh-book-del-confirm';
|
||||||
|
o.style.cssText = 'position:absolute;inset:0;background:rgba(0,0,0,.85);color:#fff;display:flex;flex-direction:column;justify-content:center;align-items:center;border-radius:inherit;z-index:10;padding:12px;text-align:center;box-sizing:border-box';
|
||||||
|
o.innerHTML = '<div style="font-weight:600;margin-bottom:8px;font-size:14px">' + heading + '</div>'
|
||||||
|
+ '<div style="font-size:11px;opacity:.8;margin-bottom:12px;line-height:1.4">' + sub + '</div>'
|
||||||
|
+ '<div style="display:flex;gap:8px">'
|
||||||
|
+ '<button class="btn-secondary btn-sm" data-lib-cancel style="background:rgba(255,255,255,.15);border:none;color:#fff;padding:6px 12px">Cancel</button>'
|
||||||
|
+ '<button class="btn-primary btn-sm" data-lib-ok style="background:var(--red,#ef4444);border:none;color:#fff;padding:6px 12px">Delete</button>'
|
||||||
|
+ '</div>';
|
||||||
|
o.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
o.querySelector('[data-lib-cancel]').addEventListener('click', function (e) { e.stopPropagation(); o.remove(); });
|
||||||
|
o.querySelector('[data-lib-ok]').addEventListener('click', async function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
o.innerHTML = '<span class="mdi mdi-loading mdi-spin" style="font-size:24px"></span>';
|
||||||
|
await onConfirm();
|
||||||
|
});
|
||||||
|
cardEl.appendChild(o);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.libraryRenderBooks = libraryRenderBooks;
|
||||||
|
window.libraryRenderPlays = libraryRenderPlays;
|
||||||
|
window.productionOpenInRehearser = productionOpenInRehearser;
|
||||||
|
window.productionOpenInReader = productionOpenInReader;
|
||||||
|
window.prodKey = prodKey;
|
||||||
@ -223,6 +223,8 @@ async function saveToLibrary() {
|
|||||||
}
|
}
|
||||||
toast('Saved to library', 'success');
|
toast('Saved to library', 'success');
|
||||||
rehState._keepSavedIdOnce = true;
|
rehState._keepSavedIdOnce = true;
|
||||||
|
// Sync cast voice choices into the shared character roster (existing chars only)
|
||||||
|
if (typeof castWriteBack === 'function') { try { await castWriteBack(rec.title, rehState.cast); } catch (_) {} }
|
||||||
await renderLibraryList();
|
await renderLibraryList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -560,6 +562,7 @@ function loadRecord(rec) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
renderCastList();
|
renderCastList();
|
||||||
|
rehApplySharedCast(rec.title);
|
||||||
refreshRehBackends().then(() => {
|
refreshRehBackends().then(() => {
|
||||||
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
|
if (rec.backend && $('reh-backend-select')) $('reh-backend-select').value = rec.backend;
|
||||||
if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
|
if (rec.narratorVoice && $('reh-narrator-voice')) $('reh-narrator-voice').value = rec.narratorVoice;
|
||||||
@ -567,6 +570,32 @@ function loadRecord(rec) {
|
|||||||
showPhase(2);
|
showPhase(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fill any empty cast slots from the production's shared character roster
|
||||||
|
// (character library, matched by book OR tag). Only blanks are filled — an
|
||||||
|
// explicit voice/soul already on the rehearsal always wins. Re-renders if it
|
||||||
|
// changed anything.
|
||||||
|
async function rehApplySharedCast(title) {
|
||||||
|
if (typeof castForProduction !== 'function' || !title) return;
|
||||||
|
let roster;
|
||||||
|
try { roster = await castForProduction(title); } catch (_) { return; }
|
||||||
|
if (!roster) return;
|
||||||
|
let changed = false;
|
||||||
|
Object.keys(rehState.cast || {}).forEach(sp => {
|
||||||
|
if (String(sp).includes('NARRATOR')) return;
|
||||||
|
const shared = roster[String(sp).toLowerCase()];
|
||||||
|
if (!shared) return;
|
||||||
|
const slot = rehState.cast[sp];
|
||||||
|
if (shared.voice && !slot.voice) {
|
||||||
|
slot.voice = shared.voice;
|
||||||
|
slot.voiceData = (shared.voice !== 'me' && typeof getVoiceData === 'function') ? getVoiceData(shared.voice) : null;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (shared.gender && !slot.gender) { slot.gender = shared.gender; changed = true; }
|
||||||
|
if (shared.soul && !slot.soul) { slot.soul = shared.soul; changed = true; }
|
||||||
|
});
|
||||||
|
if (changed) renderCastList();
|
||||||
|
}
|
||||||
|
|
||||||
// ── Library UI ─────────────────────────────────────────────────────────────
|
// ── Library UI ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Deterministic cover gradient from the title string
|
// Deterministic cover gradient from the title string
|
||||||
|
|||||||
@ -4,7 +4,7 @@
|
|||||||
const SECTIONS = [
|
const SECTIONS = [
|
||||||
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
's-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser',
|
||||||
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
's-reader', 's-performance', 's-routing', 's-connect', 's-settings',
|
||||||
's-llms', 's-conversation', 's-characters',
|
's-llms', 's-conversation', 's-library',
|
||||||
];
|
];
|
||||||
|
|
||||||
function _storedSection() {
|
function _storedSection() {
|
||||||
@ -127,6 +127,7 @@
|
|||||||
'/static/js/audiobook.js',
|
'/static/js/audiobook.js',
|
||||||
'/static/js/character-sheets.js',
|
'/static/js/character-sheets.js',
|
||||||
'/static/js/characters-library.js',
|
'/static/js/characters-library.js',
|
||||||
|
'/static/js/library.js',
|
||||||
];
|
];
|
||||||
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
|
var _useBundle = window.APP_USE_BUNDLE === true || location.search.indexOf('bundle=1') !== -1;
|
||||||
var _bundleOk = false;
|
var _bundleOk = false;
|
||||||
|
|||||||
@ -18,7 +18,7 @@
|
|||||||
llms: 's-llms'
|
llms: 's-llms'
|
||||||
};
|
};
|
||||||
|
|
||||||
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-characters'];
|
const SECTIONS = ['s-voices', 's-clone', 's-design', 's-studio', 's-tryout', 's-rehearser', 's-reader', 's-performance', 's-routing', 's-connect', 's-settings', 's-llms', 's-conversation', 's-library'];
|
||||||
|
|
||||||
function storedSection() {
|
function storedSection() {
|
||||||
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
try { return localStorage.getItem('ttsvc_section') || ''; } catch (_) { return ''; }
|
||||||
@ -71,10 +71,11 @@
|
|||||||
|
|
||||||
var TREES = [
|
var TREES = [
|
||||||
{ tree: 'nav-voices-tree', chevron: 'nav-voices-chevron', section: 's-voices' },
|
{ tree: 'nav-voices-tree', chevron: 'nav-voices-chevron', section: 's-voices' },
|
||||||
|
{ tree: 'nav-tags-tree', chevron: 'nav-tags-chevron', section: 's-voices' },
|
||||||
{ tree: 'nav-engines-tree', chevron: 'nav-engines-chevron', section: 's-llms' },
|
{ tree: 'nav-engines-tree', chevron: 'nav-engines-chevron', section: 's-llms' },
|
||||||
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
|
{ tree: 'nav-settings-tree', chevron: 'nav-settings-chevron', section: 's-settings' },
|
||||||
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
|
{ tree: 'nav-rehearser-tree', chevron: 'nav-rehearser-chevron', section: 's-rehearser' },
|
||||||
{ tree: 'nav-reader-tree', chevron: 'nav-reader-chevron', section: 's-reader' },
|
{ tree: 'nav-library-tree', chevron: 'nav-library-chevron', section: 's-library' },
|
||||||
];
|
];
|
||||||
|
|
||||||
function _applyTreeStates() {
|
function _applyTreeStates() {
|
||||||
@ -98,7 +99,7 @@
|
|||||||
// Leaving the Read Aloud reader: stop playback so audio doesn't keep running
|
// 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.readerStop === 'function') window.readerStop();
|
||||||
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
if (sectionId === 's-reader' && typeof window.readerOnShow === 'function') window.readerOnShow();
|
||||||
if (sectionId === 's-characters' && typeof window.clRender === 'function') window.clRender();
|
if (sectionId === 's-library' && typeof window.libraryRender === 'function') window.libraryRender(window._libraryView || 'books');
|
||||||
setStoredSection(sectionId);
|
setStoredSection(sectionId);
|
||||||
setSectionHash(sectionId);
|
setSectionHash(sectionId);
|
||||||
SECTIONS.forEach(function (id) {
|
SECTIONS.forEach(function (id) {
|
||||||
@ -182,30 +183,46 @@
|
|||||||
function n(fn) { return voices.filter(fn).length; }
|
function n(fn) { return voices.filter(fn).length; }
|
||||||
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; }
|
function set(id, v) { var el = document.getElementById(id); if (el) el.textContent = v || ''; }
|
||||||
set('ntc-all', n(function () { return true; }));
|
set('ntc-all', n(function () { return true; }));
|
||||||
set('ntc-cloned', n(function (v) { return v.has_ref; }));
|
|
||||||
set('ntc-designed', n(function (v) { return !v.has_ref; }));
|
|
||||||
set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
|
set('ntc-favorites',n(function (v) { return (v.rating || 0) >= 4; }));
|
||||||
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
|
set('ntc-hidden', n(function (v) { return v.enabled === false; }));
|
||||||
set('nav-voices-count', voices.length);
|
set('nav-voices-count', voices.length);
|
||||||
|
window.buildTagsTree(voices);
|
||||||
|
};
|
||||||
|
|
||||||
// ── Virtual tag "subfolders" under the fixed categories ──────────────────
|
// ── Tags tree — distinct voice tags with counts, plus the computed
|
||||||
var tree = document.getElementById('nav-voices-tree');
|
// Cloned / Designed / Favourites predicates that moved out of Library ──
|
||||||
if (!tree) return;
|
window.buildTagsTree = function (voices) {
|
||||||
tree.querySelectorAll('.nav-tree-tag').forEach(function (e) { e.remove(); });
|
voices = voices || window._voices;
|
||||||
|
var tree = document.getElementById('nav-tags-tree');
|
||||||
|
if (!tree || !voices) return;
|
||||||
|
tree.innerHTML = '';
|
||||||
|
function n(fn) { return voices.filter(fn).length; }
|
||||||
|
|
||||||
|
// Computed predicate rows (not literal tag strings) → navVoicesCat
|
||||||
|
var preds = [
|
||||||
|
{ cat: 'cloned', icon: 'microphone-variant', label: 'Cloned', count: n(function (v) { return v.has_ref; }) },
|
||||||
|
{ cat: 'designed', icon: 'auto-fix', label: 'Designed', count: n(function (v) { return !v.has_ref; }) },
|
||||||
|
{ cat: 'favorites', icon: 'star-outline', label: 'Favourites', count: n(function (v) { return (v.rating || 0) >= 4; }) },
|
||||||
|
];
|
||||||
|
preds.forEach(function (p) {
|
||||||
|
var el = document.createElement('div');
|
||||||
|
el.className = 'nav-tree-item' + (window._voiceSidebarCat === p.cat && !window._voiceTagFilter ? ' is-active' : '');
|
||||||
|
el.dataset.voiceCat = p.cat;
|
||||||
|
el.innerHTML = '<span class="mdi mdi-' + p.icon + '"></span> ' + p.label + ' <span class="ntc">' + (p.count || '') + '</span>';
|
||||||
|
el.addEventListener('click', function () { window.navVoicesCat(p.cat); });
|
||||||
|
tree.appendChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Literal tag strings → navVoicesTag (filters via _voiceTagFilter)
|
||||||
var counts = {};
|
var counts = {};
|
||||||
voices.forEach(function (v) {
|
voices.forEach(function (v) {
|
||||||
String(v.tag || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean)
|
String(v.tag || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean)
|
||||||
.forEach(function (t) { counts[t] = (counts[t] || 0) + 1; });
|
.forEach(function (t) { counts[t] = (counts[t] || 0) + 1; });
|
||||||
});
|
});
|
||||||
var names = Object.keys(counts).sort(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); });
|
var names = Object.keys(counts).sort(function (a, b) { return a.toLowerCase().localeCompare(b.toLowerCase()); });
|
||||||
if (!names.length) return;
|
|
||||||
var lbl = document.createElement('div');
|
|
||||||
lbl.className = 'nav-tree-tag nav-tree-tag-label';
|
|
||||||
lbl.textContent = 'Tags';
|
|
||||||
tree.appendChild(lbl);
|
|
||||||
names.forEach(function (tag) {
|
names.forEach(function (tag) {
|
||||||
var el = document.createElement('div');
|
var el = document.createElement('div');
|
||||||
el.className = 'nav-tree-item nav-tree-tag' + (window._voiceTagFilter === tag ? ' is-active' : '');
|
el.className = 'nav-tree-item' + (window._voiceTagFilter === tag ? ' is-active' : '');
|
||||||
el.dataset.voiceTag = tag;
|
el.dataset.voiceTag = tag;
|
||||||
var icon = document.createElement('span'); icon.className = 'mdi mdi-tag-outline'; el.appendChild(icon);
|
var icon = document.createElement('span'); icon.className = 'mdi mdi-tag-outline'; el.appendChild(icon);
|
||||||
el.appendChild(document.createTextNode(' ' + tag + ' '));
|
el.appendChild(document.createTextNode(' ' + tag + ' '));
|
||||||
|
|||||||
44
static/sections/s-library.html
Normal file
44
static/sections/s-library.html
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
<div class="section-head">
|
||||||
|
<span class="section-icon"><span class="mdi mdi-bookshelf"></span></span>
|
||||||
|
<div class="section-title">
|
||||||
|
<h2>Library</h2>
|
||||||
|
<p>Your productions in one place — books to read aloud, theater plays to rehearse, and the shared character cast that links them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lib-tabs">
|
||||||
|
<button class="lib-tab is-active" data-library-view="books" onclick="navLibraryView('books')"><span class="mdi mdi-book-open-page-variant-outline"></span> Books</button>
|
||||||
|
<button class="lib-tab" data-library-view="plays" onclick="navLibraryView('plays')"><span class="mdi mdi-theater"></span> Theater Plays</button>
|
||||||
|
<button class="lib-tab" data-library-view="characters" onclick="navLibraryView('characters')"><span class="mdi mdi-account-box-multiple-outline"></span> Characters / Cast</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Books -->
|
||||||
|
<div class="lib-panel is-active" data-library-panel="books">
|
||||||
|
<div id="lib-books-list" class="reh-bookshelf"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Theater Plays -->
|
||||||
|
<div class="lib-panel" data-library-panel="plays">
|
||||||
|
<div id="lib-plays-list" class="reh-bookshelf"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Characters / Cast -->
|
||||||
|
<div class="lib-panel" data-library-panel="characters">
|
||||||
|
<div class="cl-toolbar card" style="padding:12px; display:flex; gap:10px; align-items:center; flex-wrap:wrap">
|
||||||
|
<label class="cl-tool-field">
|
||||||
|
<span class="cl-tool-label"><span class="mdi mdi-tag-multiple-outline"></span> Production</span>
|
||||||
|
<select id="cl-book-filter"><option value="">All productions</option></select>
|
||||||
|
</label>
|
||||||
|
<label class="cl-tool-field" style="flex:1; min-width:180px">
|
||||||
|
<span class="cl-tool-label"><span class="mdi mdi-magnify"></span> Search</span>
|
||||||
|
<input id="cl-search" type="text" placeholder="Name, alias, archetype, tag…" spellcheck="false">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="cl-grid" class="cl-grid">
|
||||||
|
<div class="cl-empty">
|
||||||
|
<span class="mdi mdi-account-box-multiple-outline" style="font-size:48px;opacity:0.4"></span>
|
||||||
|
<p>No characters yet.</p>
|
||||||
|
<p class="cl-empty-hint">Run <b>Character sheets</b> from Read Aloud or the Script Rehearser to populate your cast.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@ -423,6 +423,32 @@ audio { width: 100%; }
|
|||||||
.s-engines-page { display:none; flex-direction:column; gap:16px; }
|
.s-engines-page { display:none; flex-direction:column; gap:16px; }
|
||||||
.s-engines-page.is-active { display:flex; }
|
.s-engines-page.is-active { display:flex; }
|
||||||
|
|
||||||
|
/* Combined Library — tabs + panels */
|
||||||
|
.lib-tabs { display:flex; gap:6px; margin:4px 0 16px; flex-wrap:wrap; }
|
||||||
|
.lib-tab {
|
||||||
|
display:inline-flex; align-items:center; gap:6px; padding:8px 14px;
|
||||||
|
border:1px solid var(--border); border-radius:8px; background:var(--card);
|
||||||
|
color:var(--subtext); font-size:13px; font-weight:500; cursor:pointer;
|
||||||
|
transition:background .14s ease, color .14s ease, border-color .14s ease;
|
||||||
|
}
|
||||||
|
.lib-tab:hover { color:var(--text); border-color:var(--accent); }
|
||||||
|
.lib-tab.is-active { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||||
|
.lib-panel { display:none; }
|
||||||
|
.lib-panel.is-active { display:block; }
|
||||||
|
.lib-empty { display:flex; flex-direction:column; align-items:center; gap:8px; padding:40px 0; color:var(--subtext); text-align:center; }
|
||||||
|
.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; }
|
||||||
|
|
||||||
|
/* Character production tags */
|
||||||
|
.cl-card-tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:8px; }
|
||||||
|
.cl-tag-chip {
|
||||||
|
display:inline-flex; align-items:center; gap:3px; padding:2px 8px;
|
||||||
|
border-radius:10px; background:var(--chip-bg, rgba(127,127,127,.16));
|
||||||
|
color:var(--subtext); font-size:11px; font-weight:500;
|
||||||
|
}
|
||||||
|
.cl-tag-chip .mdi { font-size:12px; opacity:.7; }
|
||||||
|
|
||||||
/* Page card head */
|
/* Page card head */
|
||||||
.s-page-head { border-bottom:1px solid var(--border); padding-bottom:14px; }
|
.s-page-head { border-bottom:1px solid var(--border); padding-bottom:14px; }
|
||||||
.s-page-head h3 { font-size:16px; font-weight:700; margin:0 0 4px; color:var(--text); }
|
.s-page-head h3 { font-size:16px; font-weight:700; margin:0 0 4px; color:var(--text); }
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user