tts-voice-creator-clone-and.../static/js/library-characters.js

1198 lines
64 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.

// ── Library Characters / Cast workspace ─────────────────────────────────────
// Loaded after library.js; uses its shared production helpers and exports
// libraryRenderCharacters for the Library section router.
// ── 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 = []; }
const byId = new Map(all.map(function (rec) { return [rec.id, rec]; }));
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,
// a Cards/Table view toggle, and a Sort dropdown (all persisted, like Script
// Rehearser's cast-list controls).
const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards';
const SORT_OPTIONS = [
['tier', 'Rolle (Haupt zuerst)'],
['alpha', 'Alphabet'],
['lines', 'Anzahl Zeilen'],
['gender', 'Geschlecht'],
['voice', 'Stimme zugewiesen'],
];
const sortMode = SORT_OPTIONS.some(function (o) { return o[0] === localStorage.getItem('ttsvc_libchars_sort'); })
? localStorage.getItem('ttsvc_libchars_sort') : 'tier';
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>'
+ '<label class="lib-chars-sort"><span class="mdi mdi-sort"></span> Sort'
+ '<select id="lib-chars-sort-sel">' + SORT_OPTIONS.map(function (o) {
return '<option value="' + o[0] + '"' + (o[0] === sortMode ? ' selected' : '') + '>' + o[1] + '</option>';
}).join('') + '</select>'
+ '</label>'
+ '<div class="lib-chars-view-toggle">'
+ '<button class="btn-secondary btn-sm' + (viewMode === 'cards' ? ' is-active' : '') + '" data-view="cards"><span class="mdi mdi-view-grid-outline"></span> Cards</button>'
+ '<button class="btn-secondary btn-sm' + (viewMode === 'table' ? ' is-active' : '') + '" data-view="table"><span class="mdi mdi-table"></span> Table</button>'
+ '</div>';
bar.querySelector('#lib-chars-import').addEventListener('click', function () {
if (typeof stImportDialog === 'function') stImportDialog('', function () { libraryRenderCharacters(); });
});
bar.querySelector('#lib-chars-sort-sel').addEventListener('change', function () {
localStorage.setItem('ttsvc_libchars_sort', this.value);
libraryRenderCharacters();
});
bar.querySelectorAll('.lib-chars-view-toggle button').forEach(function (btn) {
btn.addEventListener('click', function () {
localStorage.setItem('ttsvc_libchars_view', btn.dataset.view);
libraryRenderCharacters();
});
});
container.appendChild(bar);
const _charSortCmp = {
tier: 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 || '');
},
alpha: function (a, b) { return (a.name || '').localeCompare(b.name || ''); },
lines: function (a, b) { return (b.sheet?.line_count || 0) - (a.sheet?.line_count || 0) || (a.name || '').localeCompare(b.name || ''); },
gender: function (a, b) {
const ga = String(a.sheet?.gender || 'zzz'), gb = String(b.sheet?.gender || 'zzz');
return ga.localeCompare(gb) || (a.name || '').localeCompare(b.name || '');
},
voice: function (a, b) { return (b.voice ? 1 : 0) - (a.voice ? 1 : 0) || (a.name || '').localeCompare(b.name || ''); },
};
const productions = document.createDocumentFragment();
Object.keys(byBook).sort().forEach(function (book) {
const chars = byBook[book].sort(_charSortCmp[sortMode] || _charSortCmp.tier);
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-casting-btn" data-book="' + escHtml(book) + '" title="Back to the casting script (speaker attribution)"><span class="mdi mdi-drama-masks"></span> Casting</button>'
+ '<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>'
+ '<button class="btn-secondary btn-sm lib-chars-bulk-voice-btn" data-book="' + escHtml(book) + '" title="Auto-assign a voice to every checked character" disabled><span class="mdi mdi-account-voice"></span> Auto-assign selected (<span class="lib-chars-bulk-count">0</span>)</button>'
+ '</div></div>'
+ (viewMode === 'table'
? _charsTableHtml(chars)
: '<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-casting-btn').addEventListener('click', function () {
// Back to the casting script. If a casting session is active, navTo's
// synchronous cast-restore (nav.js) reopens it directly; otherwise this
// lands on the reader so the book can be opened and cast from there.
if (typeof navTo === 'function') navTo('s-reader');
});
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(); });
});
const bulkBtn = prod.querySelector('.lib-chars-bulk-voice-btn');
const bulkCount = prod.querySelector('.lib-chars-bulk-count');
const refreshBulkBtn = function () {
const n = prod.querySelectorAll('.lib-char-select-cb:checked').length;
bulkCount.textContent = n;
bulkBtn.disabled = n === 0;
};
prod.querySelectorAll('.lib-char-select-cb').forEach(function (cb) {
cb.addEventListener('change', refreshBulkBtn);
});
bulkBtn.addEventListener('click', async function () {
const ids = [...prod.querySelectorAll('.lib-char-select-cb:checked')].map(function (cb) { return cb.dataset.charId; });
if (!ids.length) return;
bulkBtn.disabled = true;
const origLabel = bulkBtn.innerHTML;
let done = 0;
for (const id of ids) {
const rec = byId.get(id);
if (!rec) continue;
bulkBtn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Assigning ' + (++done) + ' / ' + ids.length + '…';
// Sequential, not parallel: _autoAssignVoice avoids re-using a voice
// already taken by another character in the same book by checking
// what's assigned so far — running these concurrently would have
// every call see the same "nothing assigned yet" snapshot and could
// hand out the same voice to several selected characters at once.
try { await _autoAssignVoice(rec); } catch (_) {}
}
bulkBtn.innerHTML = origLabel;
toast(`Voice assigned to ${done} character${done !== 1 ? 's' : ''}`, 'success');
libraryRenderCharacters();
});
// Wire voice selectors and auto-assign buttons
prod.querySelectorAll('.lib-char-card').forEach(function (card) {
const charId = card.dataset.charId;
const rec = byId.get(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);
});
});
productions.appendChild(prod);
});
container.appendChild(productions);
}
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);
// Every character is auto-tagged with the book it was found in (see
// clUpsert/clMergeTags) — surfacing the chips here is how you tell a
// character recurring across several books apart from a same-named one
// that's book-local, since tags (not the single `book` field) are what
// carries multi-book membership.
const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
const tagsHtml = tagList.length
? '<div class="lib-char-tags">' + tagList.map(function (t) {
return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>';
}).join('') + '</div>'
: '';
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) + '">'
+ '<label class="lib-char-select" title="Select for bulk actions" onclick="event.stopPropagation()">'
+ '<input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '">'
+ '</label>'
+ '<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>'
+ (_libStr(sh.title) ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.title)) + '</div>' : '')
+ (_libStr(sh.aliases) ? '<div class="lib-char-archetype">aka ' + escHtml(_libStr(sh.aliases)) + '</div>' : '')
+ (sh.archetype ? '<div class="lib-char-archetype">' + escHtml(_libStr(sh.archetype)) + '</div>' : '')
+ (snippet ? '<div class="lib-char-snippet">' + escHtml(snippet) + '</div>' : '')
+ tagsHtml
+ _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>';
}
// Compact alternative to the card grid for scanning a large cast at once —
// one row per character with the fields that matter for casting decisions.
// Rows carry the "lib-char-card" class too so they're picked up by the same
// avatar/voice-picker/auto-voice/export wiring the card grid already uses;
// only the HTML shape differs.
function _charsTableHtml(chars) {
const rows = chars.map(function (rec) {
const sh = rec.sheet || {};
const hue = _charHue(rec.name);
const voiceId = rec.voice ? (typeof rec.voice === 'object' ? (rec.voice.id || '') : String(rec.voice)) : '';
const voiceLang = (rec.voice && typeof rec.voice === 'object') ? (rec.voice.language || '') : '';
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' : gender ? 'mdi-gender-non-binary' : '';
const score = sh.moral_alignment_score;
const pct = score != null ? Math.max(0, Math.min(100, score)) : null;
const tagList = String(rec.tags || '').split(',').map(function (t) { return t.trim(); }).filter(Boolean);
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());
const hasPrompt = function (key) { return !!(sh[key] && String(sh[key]).trim()); };
const promptCell = function (key, title) {
return '<span class="lib-chars-tbl-check ' + (hasPrompt(key) ? 'is-yes' : 'is-no') + '" title="' + title + (hasPrompt(key) ? ': vorhanden' : ': fehlt') + '">'
+ '<span class="mdi ' + (hasPrompt(key) ? 'mdi-check-circle' : 'mdi-circle-outline') + '"></span></span>';
};
return '<tr class="lib-char-card lib-chars-tbl-row" data-char-id="' + escHtml(rec.id) + '">'
+ '<td class="lib-chars-tbl-cb" onclick="event.stopPropagation()"><input type="checkbox" class="lib-char-select-cb" data-char-id="' + escHtml(rec.id) + '"></td>'
+ '<td><div class="lib-char-avatar lib-chars-tbl-avatar" data-char-id="' + escHtml(rec.id) + '" style="background:hsl(' + hue + ',55%,38%)" title="Bild hochladen">' + avatarInner + '</div></td>'
+ '<td class="lib-chars-tbl-name">' + tierBadge + escHtml(rec.name) + '</td>'
+ '<td>' + (genderIcon ? '<span class="mdi ' + genderIcon + '"></span>' : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (sh.line_count != null ? sh.line_count : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (voiceLang ? escHtml(voiceLang) : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td>' + (pct != null ? '<div class="lib-char-align-bar" title="' + pct + '/100"><div class="lib-char-align-dot" style="left:' + pct + '%"></div></div>' : '<span class="lib-chars-tbl-dash">—</span>') + '</td>'
+ '<td class="lib-chars-tbl-voice"><div class="lib-chars-tbl-voice-wrap">' + (voiceId ? escHtml(voiceId) : '<span class="lib-chars-tbl-dash">Keine Stimme</span>')
+ '<button class="lib-char-pick-voice btn-sm">Auswahl</button><button class="lib-char-auto-voice btn-sm">Auto</button></div></td>'
+ '<td class="lib-chars-tbl-tags"><div class="lib-chars-tbl-tags-wrap">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</div></td>'
+ '<td>' + promptCell('silly_tavern_prompt', 'SillyTavern') + '</td>'
+ '<td>' + promptCell('voice_design_prompt', 'TTS Voice') + '</td>'
+ '<td>' + promptCell('image_prompt', 'Bild') + '</td>'
+ '<td><button class="lib-char-export btn-sm" title="Als SillyTavern-Karte exportieren"><span class="mdi mdi-export-variant"></span></button></td>'
+ '</tr>';
}).join('');
return '<div class="lib-chars-tbl-wrap"><table class="lib-chars-tbl">'
// table-layout:auto put a max-width'd wrapping cell (Tags) at the wrong
// physical position — its own header stayed put but the cell rendered
// stacked under the previous column instead, a Chromium auto-layout
// quirk from mixing content-based and max-width-constrained columns in
// the same row. Fixed explicit widths sidestep the whole class of bug.
+ '<colgroup><col style="width:32px"><col style="width:44px"><col style="width:160px">'
+ '<col style="width:36px"><col style="width:60px"><col style="width:80px"><col style="width:90px">'
+ '<col style="width:220px"><col style="width:200px"><col style="width:40px"><col style="width:40px">'
+ '<col style="width:40px"><col style="width:36px"></colgroup>'
+ '<thead><tr>'
+ '<th></th><th></th><th>Name</th><th title="Geschlecht">⚥</th><th title="Anzahl Zeilen">Zeilen</th><th>Sprache</th>'
+ '<th title="Moralische Gesinnung">Gut/Böse</th><th>Stimme</th><th>Tags</th>'
+ '<th title="SillyTavern-Prompt">ST</th><th title="TTS-Voice-Design-Prompt">TTS</th><th title="Bild-Prompt">Bild</th><th></th>'
+ '</tr></thead><tbody>' + rows + '</tbody></table></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>';
}
// A collapsible "ready to copy" box for one of the four external-tool prompts
// (Voice Design / Character Image / SillyTavern / Concept Art). Closed by
// default; editable in place via the same contenteditable+data-sheet-key
// convention the rest of the detail view uses, so edits autosave for free.
function _lcdPromptBox(label, value, sheetKey) {
const has = !!(value && String(value).trim());
return '<details class="lcd-prompt-box">'
+ '<summary>' + escHtml(label) + (has ? '' : ' <span class="lcd-prompt-empty">— not generated yet</span>') + '</summary>'
+ '<div class="lcd-prompt-body">'
+ '<div class="lcd-prompt-text lcd-field-editable" contenteditable="true" spellcheck="false" data-sheet-key="' + escHtml(sheetKey) + '">' + escHtml(value || '') + '</div>'
+ '<div class="lcd-prompt-actions">'
+ '<button type="button" class="btn-secondary btn-sm lcd-prompt-copy" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-content-copy"></span> Copy</button>'
+ '<button type="button" class="btn-secondary btn-sm lcd-gen-prompt" data-sheet-key="' + escHtml(sheetKey) + '"><span class="mdi mdi-creation"></span> ' + (has ? 'Regenerate' : 'Generate') + '</button>'
+ '</div>'
+ '</div>'
+ '</details>';
}
// sourceIdxs (optional): indices into the character's sources list — shown
// as small clickable "[N]" links next to the label so a reader who doubts a
// fact can jump straight to the exact page + quote it was extracted from,
// instead of scanning the whole "Quellen im Text" list at the bottom.
function _lcdFieldEdit(label, value, sheetKey, sourceIdxs) {
const v = _libStr(value);
const links = (sourceIdxs || []).map(function (idx) {
return '<a href="#" class="lcd-field-src-link" data-jump-src="' + idx + '" title="Quelle im Text anzeigen">' + (sourceIdxs.indexOf(idx) + 1) + '</a>';
}).join('');
return '<div class="lcd-field">'
+ (label || links ? '<div class="lcd-field-label">' + escHtml(label) + (links ? ' <span class="lcd-field-src-links">' + links + '</span>' : '') + '</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 promptsHtml = '<div class="lcd-section-full lcd-prompts-section">'
+ '<div class="lcd-section-label"><span class="mdi mdi-script-text-outline"></span> Generation Prompts</div>'
+ _lcdPromptBox('Voice Design Prompt', sh.voice_design_prompt, 'voice_design_prompt')
+ _lcdPromptBox('Character Image Prompt', sh.image_prompt, 'image_prompt')
+ _lcdPromptBox('SillyTavern Character Prompt', sh.silly_tavern_prompt, 'silly_tavern_prompt')
+ _lcdPromptBox('Concept Art Prompt', sh.concept_art_prompt, 'concept_art_prompt')
+ '</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); }) : [];
// Group source indices by the sheet field they were extracted for
// (line_hint), so each detail field below can show "jump to this exact
// citation" links instead of making the reader search the full list.
const sourcesByField = {};
sourcesList.forEach(function (s, idx) {
const key = _libStr(s.line_hint || s.hint || '').trim().toLowerCase();
if (!key) return;
(sourcesByField[key] = sourcesByField[key] || []).push(idx);
});
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, idx) {
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" id="lcd-src-' + idx + '" 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>'
+ (_libStr(sh.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="full_name" spellcheck="false">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
+ (_libStr(sh.title) ? '<div class="lcd-aliases" contenteditable="true" data-sheet-key="title" spellcheck="false">' + escHtml(_libStr(sh.title)) + '</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-card-account-details-outline', 'Identität', [
_lcdFieldEdit('Voller Name', sh.full_name, 'full_name', sourcesByField['full_name']),
_lcdFieldEdit('Vorname', sh.first_name, 'first_name', sourcesByField['first_name']),
_lcdFieldEdit('Nachname', sh.last_name, 'last_name', sourcesByField['last_name']),
_lcdFieldEdit('Geschlecht', sh.gender, 'gender', sourcesByField['gender']),
_lcdFieldEdit('Titel', sh.title, 'title', sourcesByField['title']),
_lcdFieldEdit('Beruf / Rolle', sh.profession, 'profession', sourcesByField['profession']),
_lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']),
])
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
_lcdFieldEdit('Körperlich', sh.physical, 'physical', sourcesByField['physical']),
_lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing', sourcesByField['clothing']),
])
+ _lcdSection('mdi-drama-masks', 'Persönlichkeit', [
_lcdFieldEdit('Eigenheiten & Verhalten', sh.mannerisms, 'mannerisms', sourcesByField['mannerisms']),
_lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern', sourcesByField['voice_pattern']),
])
+ _lcdSection('mdi-book-open-outline', 'Geschichte', [
_lcdFieldEdit('Hintergrund & Herkunft', sh.backstory, 'backstory', sourcesByField['backstory']),
_lcdFieldEdit('Motivation', sh.motivation, 'motivation', sourcesByField['motivation']),
_lcdFieldEdit('Ängste', sh.fears, 'fears', sourcesByField['fears']),
])
+ _lcdSection('mdi-sword', 'Fähigkeiten', [
_lcdFieldEdit('Fertigkeiten', sh.skills, 'skills', sourcesByField['skills']),
_lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities', sourcesByField['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', sourcesByField['relationships']),
relDotsHtml,
])
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
_lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style', sourcesByField['conflict_style']),
_lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition', sourcesByField['win_condition']),
])
+ _lcdSection('mdi-eye-outline', 'Geheimnisse & Bogen', [
_lcdFieldEdit('Dunkles Geheimnis / fataler Fehler', sh.secret, 'secret'),
_lcdFieldEdit('Charakterentwicklung', sh.arc_note, 'arc_note'),
])
+ promptsHtml
+ '</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); });
// Generation Prompts section: copy buttons + one-call generation of all four
// external-tool prompts (Voice Design / Image / SillyTavern / Concept Art)
// from the character's full profile.
pg.querySelectorAll('.lcd-prompt-copy').forEach(function (btn) {
btn.addEventListener('click', async function () {
const box = btn.closest('.lcd-prompt-body');
const text = box?.querySelector('.lcd-prompt-text')?.textContent.trim() || '';
if (!text) { toast('Nothing to copy yet — click Generate first', 'error'); return; }
if (typeof copyText === 'function') await copyText(text);
toast('Prompt copied', 'success');
});
});
// One Generate button per prompt box — the server accepts a `fields` filter
// so clicking just one no longer burns tokens (re)generating all four.
pg.querySelectorAll('.lcd-gen-prompt').forEach(function (btn) {
btn.addEventListener('click', async function (e) {
e.preventDefault();
const key = btn.dataset.sheetKey;
const orig = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<span class="mdi mdi-loading mdi-spin"></span> Generating…';
try {
const sh2 = rec.sheet || {};
const sample = [sh2.physical, sh2.backstory, sh2.motivation].filter(Boolean).join(' ');
const language = (typeof detectLang === 'function' && sample) ? (detectLang(sample) || '') : '';
const target = (typeof statusLlmTarget === 'function') ? statusLlmTarget() : { url: '', model: '' };
const r = await fetch('/api/character-generate-prompts', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: rec.name, book: rec.book || '', sheet: sh2, language, llm_url: target.url, model: target.model, fields: [key] }),
});
if (!r.ok) throw new Error((await r.json().catch(function () { return {}; })).detail || r.statusText);
const d = await r.json();
if (!d[key]) throw new Error('Empty response — try again');
if (!rec.sheet) rec.sheet = {};
rec.sheet[key] = d[key];
rec.updated = new Date();
if (typeof clPut === 'function') await clPut(rec);
toast('Prompt generated', 'success');
_charDetailPage(rec, allChars); // re-render so the box shows the new content
} catch (err) {
toast('Prompt generation failed: ' + (err.message || err), 'error');
btn.disabled = false;
btn.innerHTML = orig;
}
});
});
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.full_name) && _libStr(sh.full_name).toLowerCase() !== String(rec.name || '').toLowerCase() ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.full_name)) + '</div>' : '')
+ (_libStr(sh.title) ? '<div class="lcd-aliases">' + escHtml(_libStr(sh.title)) + '</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-card-account-details-outline', 'Identität', [
_lcdField('Voller Name', sh.full_name, true),
_lcdField('Vorname', sh.first_name, true),
_lcdField('Nachname', sh.last_name, true),
_lcdField('Geschlecht', sh.gender, true),
_lcdField('Titel', sh.title, true),
_lcdField('Beruf / Rolle', sh.profession, true),
_lcdField('Auch bekannt als', sh.aliases, true),
])
+ _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;