tts-voice-creator-clone-and.../static/js/library.js
2026-06-30 13:53:08 +02:00

1233 lines
61 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ── 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') 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) {
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;
// 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');
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;
list.innerHTML = _libSkeleton(3);
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);
}
// ── 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;
window.productionOpenInReader = productionOpenInReader;
window.prodKey = prodKey;