Add character table view, bulk voice-assign, source links, fix prompt truncation (v1.12.98)
Table view: one row per character (avatar, name, sex, line count, voice language, alignment, voice, tags, prompt-availability checks), toggled next to the card grid and persisted. Bulk voice auto-assign: checkbox per character + "Auto-assign selected" per production, sequential so later picks see what earlier ones just took (avoids duplicate voice assignments). Character tags (auto-set to the book of origin) are now visible on cards - the field always existed, cards just never rendered it, so a character recurring across books had no visible link between records. Detail fields (Backstory, Motivation, etc.) now show small numbered links to their exact source citation when the sheet has one, instead of making the reader search the full "Quellen im Text" list. Added gender as an actual extracted character-sheet field - the UI already had a gender icon but the LLM was never asked for the value. Fixed SillyTavern/Concept Art prompts still coming back empty despite the earlier token-budget increase: they're the last two fields in one JSON object, so truncation always cost the same two regardless of the ceiling. Split into two independent, concurrent LLM calls instead. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
3fd4f7d052
commit
5fd1660c09
15
CHANGELOG.md
15
CHANGELOG.md
@ -9,6 +9,21 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
||||
|
||||
---
|
||||
|
||||
## [1.12.98] — 2026-07-05
|
||||
|
||||
### Added
|
||||
- **Character table view** in Library → Characters/Cast (toggle next to "Import from SillyTavern") — one row per character with avatar, name, sex, line count, assigned voice's language, moral alignment bar, voice, tags, and check/cross indicators for whether the SillyTavern, TTS voice-design, and image prompts have been generated. Persists your choice between Cards/Table.
|
||||
- **Bulk voice auto-assign** — a checkbox on every character card/row plus an "Auto-assign selected" button in each production's header, so you can voice-cast a batch of characters in one click instead of one at a time. Runs sequentially (not in parallel) so each assignment sees what the previous one just picked and doesn't hand out the same voice twice.
|
||||
- **Character tags are now visible** on cards — every character is auto-tagged with the book it was extracted from (already happened silently); the cards just weren't showing it. Useful for spotting the same character recurring across different books.
|
||||
- **Jump-to-source links on detail fields** — Backstory, Motivation, Relationships, etc. now show small numbered links next to the label when the sheet has a citation for that field; clicking one scrolls straight to the exact page + quote in "Quellen im Text" instead of making you search the full source list.
|
||||
- **Gender is now an extracted character-sheet field** (male/female/nonbinary) — it existed in the UI already but the LLM was never actually asked for it, so every character silently showed the same default icon.
|
||||
- **Line-count tracking (best-effort)** — casting from Read Aloud or Script Rehearsal now tallies each character's dialogue-line count into their sheet when it's available in memory, feeding the new table view's "Zeilen" column.
|
||||
|
||||
### Fixed
|
||||
- **SillyTavern & Concept Art prompts still silently failed to generate** even after the earlier max_tokens increase — they're the last two fields in a four-field JSON object, so any truncation always cost the same two fields regardless of the ceiling. Split into two independent LLM calls (Voice Design + Image / SillyTavern + Concept Art), run concurrently, each with its own full token budget — a squeeze in one pair can no longer cost the other pair anything.
|
||||
|
||||
---
|
||||
|
||||
## [1.12.97] — 2026-07-05
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -599,6 +599,7 @@ async def character_sheets(request: Request):
|
||||
"- aliases: ONLY alternate names, roles, epithets, mistranscriptions, and titles proven to refer to the SAME character, comma-separated (max 6 items; e.g. 'Henker, Vampir, Zerwas der Henker'). Leave empty when uncertain.\n"
|
||||
"- first_name, last_name, full_name, title: split the character identity when known. Leave unknown parts empty. Put noble/office/role labels in title (e.g. 'Henker', 'Vampir', 'Graf').\n"
|
||||
"- archetype: a two-word role summary (e.g. 'Ruthless Scholar')\n"
|
||||
"- gender: 'male', 'female', or 'nonbinary' — as apparent from the text (pronouns, roles, physical description). Leave empty if genuinely indeterminable.\n"
|
||||
"- physical: age, height, build, hair, eyes, skin, posture, gait, vocal quality. Use ONLY metric system.\n"
|
||||
"- clothing: distinctive clothing, armour, accessories — as observed in the text\n"
|
||||
"- alignment: strict moral code + the one line they will never cross\n"
|
||||
@ -630,7 +631,7 @@ async def character_sheets(request: Request):
|
||||
"Reuse the EXACT names from the known-characters list for returning characters when they are the canonical name or an alias of this character. "
|
||||
"Do not merge characters merely because their names appear near each other, in the known-character list, or in relationships.\n"
|
||||
"Respond with STRICT JSON only:\n"
|
||||
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","physical":"","clothing":"",'
|
||||
'{"sheets":[{"name":"","aliases":"","first_name":"","last_name":"","full_name":"","title":"","archetype":"","gender":"","physical":"","clothing":"",'
|
||||
'"alignment":"","moral_alignment_score":50,"arc_direction":"neutral","arc_note":"",'
|
||||
'"attribute_high":"","attribute_low":"","skills":"","capabilities":"",'
|
||||
'"backstory":"","relationships":"","motivation":"","fears":"","mannerisms":"","voice_pattern":"","voice_design_prompt":"","image_prompt":"",'
|
||||
@ -727,12 +728,16 @@ async def character_sheets(request: Request):
|
||||
arc = str(s.get("arc_direction") or "neutral").strip()
|
||||
if arc not in ("stable-good", "stable-bad", "neutral", "good-to-bad", "bad-to-good", "complex"):
|
||||
arc = "neutral"
|
||||
gender = str(s.get("gender") or "").strip().lower()
|
||||
if gender not in ("male", "female", "nonbinary"):
|
||||
gender = ""
|
||||
s.update({
|
||||
"name": name, "aliases": aliases, "inventory": inv[:3],
|
||||
"tier": "main" if str(s.get("tier") or "").lower().startswith("main") else "supporting",
|
||||
"sources": src[:12],
|
||||
"moral_alignment_score": mas,
|
||||
"arc_direction": arc,
|
||||
"gender": gender,
|
||||
})
|
||||
clean.append(s)
|
||||
names.append(name)
|
||||
@ -858,82 +863,102 @@ async def character_generate_prompts(request: Request):
|
||||
] if v)
|
||||
|
||||
lang_note = f" Write every prompt in {language} EXCEPT where told to use English." if language else ""
|
||||
system = (
|
||||
"You are a prompt engineer who turns a fiction character's profile into four ready-to-paste "
|
||||
"prompts for other tools. Use ONLY details present in the profile below; mark anything you must "
|
||||
f"reasonably infer with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n"
|
||||
"Produce exactly these four fields:\n"
|
||||
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
|
||||
"no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
|
||||
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
|
||||
"mention plot events — describe only how the voice should SOUND.\n"
|
||||
"- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
|
||||
"Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
|
||||
"clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
|
||||
"(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
|
||||
"are fine.\n"
|
||||
"- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
|
||||
"own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
|
||||
"'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
|
||||
"greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
|
||||
"lines showing their manner of speech). Keep each section a few lines at most.\n"
|
||||
"- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
|
||||
"a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
|
||||
"2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
|
||||
"in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
|
||||
"white background').\n\n"
|
||||
"Respond with STRICT JSON only: "
|
||||
'{"voice_design_prompt":"","image_prompt":"","silly_tavern_prompt":"","concept_art_prompt":""}/no-think'
|
||||
preamble = (
|
||||
"You are a prompt engineer who turns a fiction character's profile into ready-to-paste prompts for "
|
||||
"other tools. Use ONLY details present in the profile below; mark anything you must reasonably infer "
|
||||
f"with a trailing '*'. Never invent plot spoilers not implied by the profile.{lang_note}\n\n"
|
||||
)
|
||||
user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\nGenerate the four prompts now."
|
||||
payload: dict = {
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
# Four prompts, one of them multi-section (SillyTavern) — a tighter cap
|
||||
# truncated the JSON mid-output, the parse failed, and the two later
|
||||
# fields silently came back empty.
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"{llm_url}/chat/completions", json=payload,
|
||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_msg = resp.json()["choices"][0]["message"]
|
||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
||||
except Exception as e:
|
||||
raise HTTPException(502, f"LLM prompt generation failed: {e}")
|
||||
user = f"BOOK: {book or 'unspecified'}\n\nCHARACTER PROFILE:\n{profile or name}\n\n"
|
||||
|
||||
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||
block = _extract_json_block(content)
|
||||
# If the model still got cut off mid-string, closing the dangling string +
|
||||
# object often salvages the fields that DID complete.
|
||||
candidates = [content, block]
|
||||
if block:
|
||||
candidates.extend([block + "\"}", block + "}"])
|
||||
result = {}
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
# Split into two independent calls instead of one four-field JSON object.
|
||||
# Even at a 4096-token ceiling, the model reliably ran out of budget before
|
||||
# reaching the LAST two fields (silly_tavern_prompt, concept_art_prompt come
|
||||
# after voice_design_prompt/image_prompt in the JSON) — truncation always
|
||||
# cost the same two fields. Each pair now gets its own full token budget,
|
||||
# so a squeeze in one pair can't cost the other pair anything, and the two
|
||||
# calls run concurrently so this isn't slower than the single-call version.
|
||||
field_groups = [
|
||||
(
|
||||
("voice_design_prompt", "image_prompt"),
|
||||
preamble
|
||||
+ "Produce exactly these two fields:\n"
|
||||
"- voice_design_prompt: an English prompt for Qwen3 TTS Voice Design (15-45 words, one paragraph, "
|
||||
"no markdown). Cover: apparent age, gender/androgyny if inferable, pitch, timbre/texture, pace, "
|
||||
"accent or register, emotional baseline, and suitability for audiobook dialogue delivery. Do not "
|
||||
"mention plot events — describe only how the voice should SOUND.\n"
|
||||
"- image_prompt: a detailed English image-generation prompt for a character profile picture/portrait. "
|
||||
"Include face and expression typical of this character, age impression, build, hair/eyes/skin if known, "
|
||||
"clothing, signature tools/weapons/props, an environment typical for them, mood, and an art style "
|
||||
"(e.g. 'detailed digital painting, dramatic lighting'). One dense paragraph, comma-separated descriptors "
|
||||
"are fine.\n\n"
|
||||
'Respond with STRICT JSON only: {"voice_design_prompt":"","image_prompt":""}/no-think',
|
||||
),
|
||||
(
|
||||
("silly_tavern_prompt", "concept_art_prompt"),
|
||||
preamble
|
||||
+ "Produce exactly these two fields:\n"
|
||||
"- silly_tavern_prompt: character-card content for SillyTavern, formatted as labelled sections on their "
|
||||
"own lines: 'Description:' (physical + personality summary), 'Personality:' (a compact trait list), "
|
||||
"'Scenario:' (the situation/setting they're typically found in), 'First message:' (one in-character "
|
||||
"greeting line in their own voice/speech pattern), and 'Example dialogue:' (2-3 short in-character "
|
||||
"lines showing their manner of speech). Keep each section a few lines at most.\n"
|
||||
"- concept_art_prompt: an English prompt for a character CONCEPT SHEET (not a single portrait) — "
|
||||
"a turnaround/reference sheet with multiple views and expressions: front view, side or back view, "
|
||||
"2-3 facial expressions, and a close-up of a signature prop/costume detail, all on one clean sheet, "
|
||||
"in a character-design-sheet art style (e.g. 'character turnaround, model sheet, flat lighting, "
|
||||
"white background').\n\n"
|
||||
'Respond with STRICT JSON only: {"silly_tavern_prompt":"","concept_art_prompt":""}/no-think',
|
||||
),
|
||||
]
|
||||
|
||||
def _call_group(fields: tuple, system: str) -> dict:
|
||||
payload: dict = {
|
||||
"messages": [
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": user + f"Generate the {' and '.join(fields)} now."},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 2560,
|
||||
}
|
||||
if model:
|
||||
payload["model"] = model
|
||||
try:
|
||||
parsed = json.loads(cand)
|
||||
if isinstance(parsed, dict) and "voice_design_prompt" in parsed:
|
||||
result = parsed
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
out = {
|
||||
"voice_design_prompt": str(result.get("voice_design_prompt") or "").strip(),
|
||||
"image_prompt": str(result.get("image_prompt") or "").strip(),
|
||||
"silly_tavern_prompt": str(result.get("silly_tavern_prompt") or "").strip(),
|
||||
"concept_art_prompt": str(result.get("concept_art_prompt") or "").strip(),
|
||||
}
|
||||
resp = requests.post(
|
||||
f"{llm_url}/chat/completions", json=payload,
|
||||
headers={"Authorization": f"Bearer {_settings.get('llm_api_key') or 'sk-dummy-key'}"}, timeout=600,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
_msg = resp.json()["choices"][0]["message"]
|
||||
raw = (_msg.get("content") or _msg.get("reasoning_content") or "").strip()
|
||||
except Exception as e:
|
||||
print(f"[character-generate-prompts] LLM call failed for {fields}: {e}")
|
||||
return {}
|
||||
|
||||
content = re.sub(r"<think>.*?</think>", "", raw, flags=re.DOTALL).strip() or raw
|
||||
block = _extract_json_block(content)
|
||||
# If the model still got cut off mid-string, closing the dangling string +
|
||||
# object often salvages the field that DID complete.
|
||||
candidates = [content, block]
|
||||
if block:
|
||||
candidates.extend([block + "\"}", block + "}"])
|
||||
for cand in candidates:
|
||||
if not cand:
|
||||
continue
|
||||
try:
|
||||
parsed = json.loads(cand)
|
||||
if isinstance(parsed, dict) and fields[0] in parsed:
|
||||
return parsed
|
||||
except Exception:
|
||||
continue
|
||||
return {}
|
||||
|
||||
results = await asyncio.gather(*[
|
||||
asyncio.to_thread(_call_group, fields, system) for fields, system in field_groups
|
||||
])
|
||||
out = {}
|
||||
for (fields, _), result in zip(field_groups, results):
|
||||
for f in fields:
|
||||
out[f] = str(result.get(f) or "").strip()
|
||||
if not any(out.values()):
|
||||
# A silent all-empty response looked identical to success in the UI —
|
||||
# fail loudly so the client can show a real error instead.
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2563EB">
|
||||
<meta name="app-version" content="1.12.97">
|
||||
<meta name="app-version" content="1.12.98">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.97">
|
||||
<link rel="stylesheet" href="/static/style.css?v=1.12.98">
|
||||
|
||||
|
||||
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
|
||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
|
||||
<script src="/static/loader.js?v=1.12.97"></script>
|
||||
<script src="/static/loader.js?v=1.12.98"></script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -147,7 +147,7 @@ function csKnownReaderRoster() {
|
||||
function csBlankSheet(name) {
|
||||
return {
|
||||
name, aliases: '', first_name: '', last_name: '', full_name: '', title: '',
|
||||
archetype: '', physical: '', clothing: '', alignment: '', arc_note: '',
|
||||
archetype: '', gender: '', physical: '', clothing: '', alignment: '', arc_note: '',
|
||||
attribute_high: '', attribute_low: '', skills: '', capabilities: '',
|
||||
backstory: '', relationships: '', motivation: '', fears: '', mannerisms: '', voice_pattern: '',
|
||||
secret: '', conflict_style: '', win_condition: '', voice_design_prompt: '', image_prompt: '',
|
||||
@ -208,6 +208,7 @@ function csMerge(map, sheets) {
|
||||
: s.moral_alignment_score;
|
||||
}
|
||||
if (s.arc_direction && s.arc_direction !== 'neutral') e.arc_direction = s.arc_direction;
|
||||
if (s.gender && !e.gender) e.gender = s.gender;
|
||||
(s.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||||
(s.sources || []).forEach(src => {
|
||||
if (src && src.quote && e.sources.length < 12 && !e.sources.some(x => x.quote === src.quote)) e.sources.push(src);
|
||||
@ -744,6 +745,19 @@ function csGoToLibrary() {
|
||||
else if (typeof libraryRender === 'function') libraryRender('characters');
|
||||
}
|
||||
|
||||
// Best-effort dialogue-line tally so the Library's table view can show a
|
||||
// "lines" column — only meaningful right after a cast/rehearsal run is in
|
||||
// memory, since line attribution itself isn't part of character-sheet
|
||||
// generation. Matches case-insensitively since sheet names and roster/script
|
||||
// speaker names aren't guaranteed identical casing.
|
||||
function csAttachLineCounts(sheets, counts) {
|
||||
if (!counts || !counts.size) return;
|
||||
sheets.forEach(function (s) {
|
||||
const n = counts.get(String(s.name || '').trim().toLowerCase());
|
||||
if (n != null) s.line_count = n;
|
||||
});
|
||||
}
|
||||
|
||||
async function csForReader() {
|
||||
const text = csReaderText();
|
||||
const book = readerState.title || 'Untitled book';
|
||||
@ -753,6 +767,12 @@ async function csForReader() {
|
||||
const sheets = await csGenerate(text, key, knownRoster);
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
const ab = (typeof _audiobook !== 'undefined') ? _audiobook : window._audiobook;
|
||||
if (ab?.roster) {
|
||||
const counts = new Map();
|
||||
ab.roster.forEach(function (info, name) { counts.set(String(name).trim().toLowerCase(), info.count || 0); });
|
||||
csAttachLineCounts(sheets, counts);
|
||||
}
|
||||
await csSaveToLibrary(book, sheets);
|
||||
csGoToLibrary();
|
||||
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
||||
@ -767,6 +787,15 @@ async function csForRehearser() {
|
||||
const sheets = await csGenerate(text, key);
|
||||
if (!sheets) return;
|
||||
if (!sheets.length) { toast('No characters found', 'error'); return; }
|
||||
if (window.rehState?.lines?.length) {
|
||||
const counts = new Map();
|
||||
rehState.lines.forEach(function (l) {
|
||||
if (l.type !== 'dialog' || !l.speaker) return;
|
||||
const k = String(l.speaker).trim().toLowerCase();
|
||||
counts.set(k, (counts.get(k) || 0) + 1);
|
||||
});
|
||||
csAttachLineCounts(sheets, counts);
|
||||
}
|
||||
await csSaveToLibrary(book, sheets);
|
||||
csGoToLibrary();
|
||||
toast(sheets.length + ' character sheets saved — Library → Characters / Cast', 'success');
|
||||
|
||||
@ -156,6 +156,8 @@ function clMergeSheet(existing, incoming) {
|
||||
: incoming.moral_alignment_score;
|
||||
}
|
||||
if (incoming.arc_direction && incoming.arc_direction !== 'neutral') e.arc_direction = incoming.arc_direction;
|
||||
if (incoming.gender && !e.gender) e.gender = incoming.gender;
|
||||
if (incoming.line_count != null) e.line_count = incoming.line_count;
|
||||
e.inventory = [...(existing.inventory || [])];
|
||||
(incoming.inventory || []).forEach(it => { if (it && !e.inventory.includes(it) && e.inventory.length < 3) e.inventory.push(it); });
|
||||
e.sources = [...(existing.sources || [])];
|
||||
|
||||
@ -38,13 +38,25 @@ async function libraryRenderCharacters() {
|
||||
|
||||
container.innerHTML = '';
|
||||
|
||||
// Global toolbar — import a cast from SillyTavern into a new/unsorted production
|
||||
// Global toolbar — import a cast from SillyTavern into a new/unsorted production,
|
||||
// plus a Cards/Table view toggle (persisted, like Script Rehearser's).
|
||||
const viewMode = localStorage.getItem('ttsvc_libchars_view') === 'table' ? 'table' : 'cards';
|
||||
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.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>'
|
||||
+ '<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.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 productions = document.createDocumentFragment();
|
||||
@ -67,10 +79,13 @@ async function libraryRenderCharacters() {
|
||||
+ '<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>'
|
||||
+ '<div class="lib-chars-grid" id="lib-chars-grid-' + encodeURIComponent(book).replace(/%/g,'_') + '">'
|
||||
+ chars.map(function (rec) { return _charCardHtml(rec, chars); }).join('')
|
||||
+ '</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 () {
|
||||
@ -93,6 +108,38 @@ async function libraryRenderCharacters() {
|
||||
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;
|
||||
@ -226,12 +273,26 @@ function _charCardHtml(rec, allChars) {
|
||||
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>'
|
||||
@ -242,6 +303,7 @@ function _charCardHtml(rec, allChars) {
|
||||
+ (_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>'
|
||||
@ -253,6 +315,58 @@ function _charCardHtml(rec, allChars) {
|
||||
+ '</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">' + (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></td>'
|
||||
+ '<td class="lib-chars-tbl-tags">' + tagList.map(function (t) { return '<span class="cl-tag-chip"><span class="mdi mdi-tag-outline"></span>' + escHtml(t) + '</span>'; }).join('') + '</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">'
|
||||
+ '<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) {
|
||||
@ -314,10 +428,17 @@ function _lcdPromptBox(label, value, sheetKey) {
|
||||
+ '</details>';
|
||||
}
|
||||
|
||||
function _lcdFieldEdit(label, value, sheetKey) {
|
||||
// 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 ? '<div class="lcd-field-label">' + escHtml(label) + '</div>' : '')
|
||||
+ (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>';
|
||||
}
|
||||
@ -424,15 +545,24 @@ async function _charDetailPage(rec, allChars) {
|
||||
) : '';
|
||||
|
||||
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) {
|
||||
+ 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" data-page="' + (s.page != null ? s.page : '') + '">'
|
||||
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>';
|
||||
@ -485,38 +615,38 @@ async function _charDetailPage(rec, allChars) {
|
||||
+ alignHtml
|
||||
+ '<div class="lcd-sections">'
|
||||
+ _lcdSection('mdi-card-account-details-outline', 'Identität', [
|
||||
_lcdFieldEdit('Voller Name', sh.full_name, 'full_name'),
|
||||
_lcdFieldEdit('Vorname', sh.first_name, 'first_name'),
|
||||
_lcdFieldEdit('Nachname', sh.last_name, 'last_name'),
|
||||
_lcdFieldEdit('Titel / Rolle', sh.title, 'title'),
|
||||
_lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases'),
|
||||
_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('Titel / Rolle', sh.title, 'title', sourcesByField['title']),
|
||||
_lcdFieldEdit('Auch bekannt als', sh.aliases, 'aliases', sourcesByField['aliases']),
|
||||
])
|
||||
+ _lcdSection('mdi-account-outline', 'Erscheinung', [
|
||||
_lcdFieldEdit('Körperlich', sh.physical, 'physical'),
|
||||
_lcdFieldEdit('Kleidung & Aussehen', sh.clothing, 'clothing'),
|
||||
_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'),
|
||||
_lcdFieldEdit('Stimme & Sprache', sh.voice_pattern, 'voice_pattern'),
|
||||
_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'),
|
||||
_lcdFieldEdit('Motivation', sh.motivation, 'motivation'),
|
||||
_lcdFieldEdit('Ängste', sh.fears, 'fears'),
|
||||
_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'),
|
||||
_lcdFieldEdit('Besondere Fähigkeiten', sh.capabilities, 'capabilities'),
|
||||
_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'),
|
||||
_lcdFieldEdit('', sh.relationships, 'relationships', sourcesByField['relationships']),
|
||||
relDotsHtml,
|
||||
])
|
||||
+ _lcdSection('mdi-shield-sword-outline', 'Konflikt & Strategie', [
|
||||
_lcdFieldEdit('Konfliktstil', sh.conflict_style, 'conflict_style'),
|
||||
_lcdFieldEdit('Siegbedingung', sh.win_condition, 'win_condition'),
|
||||
_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'),
|
||||
|
||||
@ -558,7 +558,33 @@ audio { width: 100%; }
|
||||
.lib-chars-loading { padding:32px; text-align:center; color:var(--subtext); font-size:13px; }
|
||||
|
||||
/* ── Characters / Cast view ─────────────────────────────────────────────────── */
|
||||
.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; }
|
||||
.lib-chars-toolbar { display:flex; gap:8px; margin-bottom:16px; flex-wrap:wrap; align-items:center; }
|
||||
.lib-chars-view-toggle { display:flex; gap:2px; margin-left:auto; }
|
||||
.lib-chars-view-toggle button.is-active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
|
||||
/* Table view — dense alternative to the card grid for scanning a large cast */
|
||||
.lib-chars-tbl-wrap { overflow-x:auto; border:1px solid var(--border); border-radius:8px; }
|
||||
.lib-chars-tbl { width:100%; border-collapse:collapse; font-size:12.5px; white-space:nowrap; }
|
||||
.lib-chars-tbl thead th {
|
||||
text-align:left; padding:8px 10px; font-size:10.5px; font-weight:800; text-transform:uppercase;
|
||||
letter-spacing:.04em; color:var(--subtext); border-bottom:1px solid var(--border); background:var(--panel);
|
||||
position:sticky; top:0; z-index:1;
|
||||
}
|
||||
.lib-chars-tbl td { padding:6px 10px; border-bottom:1px solid var(--border); vertical-align:middle; }
|
||||
.lib-chars-tbl-row { cursor:pointer; }
|
||||
.lib-chars-tbl-row:hover { background:var(--panel); }
|
||||
.lib-chars-tbl-avatar { width:28px; height:28px; font-size:12px; border:none; box-shadow:none; }
|
||||
.lib-chars-tbl-row .lib-char-export { position:static; opacity:1; width:auto; height:auto; border:0; background:none; color:var(--subtext); padding:2px; }
|
||||
.lib-chars-tbl-row .lib-char-export:hover { color:var(--accent); background:none; border:0; }
|
||||
.lib-chars-tbl-name { font-weight:600; white-space:normal; min-width:120px; }
|
||||
.lib-chars-tbl-voice { display:flex; align-items:center; gap:6px; }
|
||||
.lib-chars-tbl-voice button { padding:2px 7px; font-size:11px; }
|
||||
.lib-chars-tbl-tags { white-space:normal; display:flex; flex-wrap:wrap; gap:3px; max-width:220px; }
|
||||
.lib-chars-tbl-dash { opacity:.4; }
|
||||
.lib-chars-tbl-check { display:inline-flex; }
|
||||
.lib-chars-tbl-check.is-yes { color:#4caf50; }
|
||||
.lib-chars-tbl-check.is-no { color:var(--subtext); opacity:.35; }
|
||||
.lib-chars-tbl td .lib-char-align-bar { width:60px; flex:none; display:inline-block; }
|
||||
.lib-chars-production { margin-bottom:28px; }
|
||||
.lib-char-card .lib-char-export {
|
||||
position:absolute; top:6px; right:6px; z-index:2;
|
||||
@ -597,6 +623,12 @@ audio { width: 100%; }
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,.08);
|
||||
}
|
||||
.lib-char-card:hover { transform:translateY(-3px); box-shadow:0 8px 22px rgba(0,0,0,.16); }
|
||||
.lib-char-select {
|
||||
position:absolute; top:8px; left:8px; z-index:3; cursor:pointer;
|
||||
width:20px; height:20px; display:flex; align-items:center; justify-content:center;
|
||||
background:rgba(0,0,0,.35); border-radius:5px;
|
||||
}
|
||||
.lib-char-select-cb { width:15px; height:15px; cursor:pointer; }
|
||||
/* Coloured banner at top of each card */
|
||||
.lib-char-card-banner {
|
||||
height:90px; display:flex; align-items:center; justify-content:center; position:relative;
|
||||
@ -926,6 +958,7 @@ audio { width: 100%; }
|
||||
color:var(--subtext); font-size:11px; font-weight:500;
|
||||
}
|
||||
.cl-tag-chip .mdi { font-size:12px; opacity:.7; }
|
||||
.lib-char-tags { display:flex; flex-wrap:wrap; gap:4px; margin:4px 0; }
|
||||
|
||||
/* Page card head */
|
||||
.s-page-head { border-bottom:1px solid var(--border); padding-bottom:14px; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user