feat: Enhance manual override functionality with status updates and improved UI elements

This commit is contained in:
JB
2025-10-12 17:36:37 -07:00
parent a2c08b1b4d
commit eb78347a85
3 changed files with 263 additions and 28 deletions
+181 -26
View File
@@ -1168,6 +1168,36 @@ const initPrepare = (root = document) => {
const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]'); const manualOverrideSearchButton = manualOverridesRoot?.querySelector('[data-role="manual-override-search"]');
const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]'); const manualOverrideAddCustomButton = manualOverridesRoot?.querySelector('[data-role="manual-override-add-custom"]');
const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]'); const manualOverridesEmpty = manualOverridesRoot?.querySelector('[data-role="manual-overrides-empty"]');
const manualOverrideSaveButton = manualOverridesRoot?.querySelector('[data-role="manual-override-save-all"]');
const manualOverrideStatusNode = manualOverridesRoot?.querySelector('[data-role="manual-override-status"]');
let manualOverrideStatusTimer = null;
let manualOverrideStatusNonce = 0;
const setManualOverrideStatus = (message, state = "") => {
if (!manualOverrideStatusNode) return;
manualOverrideStatusNonce += 1;
const nonce = manualOverrideStatusNonce;
if (manualOverrideStatusTimer) {
window.clearTimeout(manualOverrideStatusTimer);
manualOverrideStatusTimer = null;
}
manualOverrideStatusNode.textContent = message || "";
if (state) {
manualOverrideStatusNode.dataset.state = state;
} else {
manualOverrideStatusNode.removeAttribute("data-state");
}
if (state === "success" && message) {
manualOverrideStatusTimer = window.setTimeout(() => {
if (manualOverrideStatusNonce !== nonce) {
return;
}
manualOverrideStatusNode.textContent = "";
manualOverrideStatusNode.removeAttribute("data-state");
manualOverrideStatusTimer = null;
}, 4000);
}
};
if (entitiesRefreshButton) { if (entitiesRefreshButton) {
entitiesRefreshButton.disabled = !entitiesEnabled; entitiesRefreshButton.disabled = !entitiesEnabled;
@@ -1932,6 +1962,7 @@ const initPrepare = (root = document) => {
markOverrideDirty = (overrideId) => { markOverrideDirty = (overrideId) => {
if (!overrideId) return; if (!overrideId) return;
dirtyOverrideIds.add(overrideId); dirtyOverrideIds.add(overrideId);
setManualOverrideStatus("Unsaved changes", "pending");
}; };
flushManualOverrides = (options = {}) => { flushManualOverrides = (options = {}) => {
@@ -1939,6 +1970,7 @@ const initPrepare = (root = document) => {
const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds); const pendingIds = Array.isArray(overrideId) ? overrideId : overrideId ? [overrideId] : Array.from(dirtyOverrideIds);
const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id)); const targetIds = pendingIds.filter((id) => dirtyOverrideIds.has(id));
if (!targetIds.length) return null; if (!targetIds.length) return null;
setManualOverrideStatus("Saving overrides…", "pending");
targetIds.forEach((id) => dirtyOverrideIds.delete(id)); targetIds.forEach((id) => dirtyOverrideIds.delete(id));
const runFlush = async () => { const runFlush = async () => {
if (overrideFlushPromise) { if (overrideFlushPromise) {
@@ -1949,15 +1981,23 @@ const initPrepare = (root = document) => {
} }
} }
const results = await Promise.all(targetIds.map((id) => saveManualOverride(id))); const results = await Promise.all(targetIds.map((id) => saveManualOverride(id)));
let hasFailure = false;
results.forEach((ok, index) => { results.forEach((ok, index) => {
if (!ok) { if (!ok) {
hasFailure = true;
dirtyOverrideIds.add(targetIds[index]); dirtyOverrideIds.add(targetIds[index]);
} }
}); });
if (hasFailure) {
setManualOverrideStatus("Some overrides failed to save.", "error");
} else {
setManualOverrideStatus("Overrides saved.", "success");
}
}; };
overrideFlushPromise = runFlush() overrideFlushPromise = runFlush()
.catch((error) => { .catch((error) => {
console.error("Failed flushing manual overrides", error); console.error("Failed flushing manual overrides", error);
setManualOverrideStatus("Failed to save overrides.", "error");
}) })
.finally(() => { .finally(() => {
overrideFlushPromise = null; overrideFlushPromise = null;
@@ -2060,9 +2100,9 @@ const initPrepare = (root = document) => {
} }
async function createOverrideFromData(data) { async function createOverrideFromData(data) {
if (!data || !manualUpsertUrl) return; if (!data || !manualUpsertUrl) return false;
const token = (data.token || "").trim(); const token = (data.token || "").trim();
if (!token) return; if (!token) return false;
const normalized = (data.normalized || "").trim(); const normalized = (data.normalized || "").trim();
const voiceChoice = resolveOverrideVoice({ ...data, token, normalized }); const voiceChoice = resolveOverrideVoice({ ...data, token, normalized });
const payload = { const payload = {
@@ -2095,8 +2135,10 @@ const initPrepare = (root = document) => {
manualOverrideResultsList.innerHTML = ""; manualOverrideResultsList.innerHTML = "";
} }
} }
return true;
} catch (error) { } catch (error) {
console.error("Failed to create manual override", error); console.error("Failed to create manual override", error);
return false;
} }
} }
@@ -2114,7 +2156,103 @@ const initPrepare = (root = document) => {
}); });
} }
const handleEntityListClick = (event) => { const extractInlineOverrideState = (inlineOverride, row) => {
if (!inlineOverride || inlineOverride.hidden) {
return null;
}
const container = row || inlineOverride.closest('[data-role="entity-row"]');
if (!container) {
return null;
}
const tokenLabel = inlineOverride.dataset.pendingToken || container.querySelector('[data-role="entity-label"]')?.textContent || "";
if (!tokenLabel) {
return null;
}
const normalized = (inlineOverride.dataset.pendingNormalized || container.dataset.normalized || tokenLabel || "").trim();
const context = (inlineOverride.dataset.pendingContext || "").trim();
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]');
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]');
const pronunciationRaw = pronInput?.value ?? "";
const pronunciation = pronunciationRaw.trim();
const voiceRaw = voiceSelect?.value ?? "";
const voice = voiceRaw;
const previewVoice = voice || baseVoice || "";
const previewText = pronunciation || tokenLabel;
return {
token: tokenLabel,
normalized,
context,
pronunciation,
voice,
previewVoice,
previewText,
category: inlineOverride.dataset.pendingCategory || container.dataset.entityCategory || "",
kind: inlineOverride.dataset.pendingKind || container.dataset.entityKind || "",
};
};
const syncInlinePreviewUI = (row, inlineOverride, state, targetButton) => {
if (!state) {
return;
}
const previewText = state.previewText || state.token;
const previewVoice = state.previewVoice || baseVoice || "";
const applyDatasets = (button) => {
if (!button) return;
button.dataset.previewText = previewText;
button.dataset.voice = previewVoice;
button.dataset.language = languageCode;
button.dataset.speed = defaultSpeed;
button.dataset.useGpu = useGpuDefault;
if (!button.dataset.previewSource || button.dataset.previewSource === "entity") {
button.dataset.previewSource = "manual";
}
};
if (targetButton) {
applyDatasets(targetButton);
return;
}
if (inlineOverride) {
applyDatasets(inlineOverride.querySelector('[data-role="speaker-preview"]'));
}
if (row) {
applyDatasets(row.querySelector('[data-entity-preview="true"]'));
}
};
const persistInlineOverrideState = (state) => {
if (!state) {
return null;
}
highlightMode = "inline";
setManualOverrideStatus("Saving overrides…", "pending");
return createOverrideFromData({
token: state.token,
normalized: state.normalized,
context: state.context,
pronunciation: state.pronunciation,
voice: state.voice,
category: state.category,
kind: state.kind,
source: "entity-inline",
})
.then((ok) => {
if (ok) {
setManualOverrideStatus("Overrides saved.", "success");
} else {
setManualOverrideStatus("Failed to save overrides.", "error");
}
return ok;
})
.catch((error) => {
setManualOverrideStatus("Failed to save overrides.", "error");
throw error;
});
};
const handleEntityListClick = (event) => {
const addTrigger = event.target.closest('[data-role="entity-add-override"]'); const addTrigger = event.target.closest('[data-role="entity-add-override"]');
if (addTrigger) { if (addTrigger) {
event.preventDefault(); event.preventDefault();
@@ -2160,13 +2298,8 @@ const initPrepare = (root = document) => {
inlineOverride.dataset.pendingCategory = addTrigger.dataset.entityCategory || row.dataset.entityCategory || ""; inlineOverride.dataset.pendingCategory = addTrigger.dataset.entityCategory || row.dataset.entityCategory || "";
inlineOverride.dataset.pendingKind = addTrigger.dataset.entityKind || row.dataset.entityKind || ""; inlineOverride.dataset.pendingKind = addTrigger.dataset.entityKind || row.dataset.entityKind || "";
if (previewButton) { const inlineState = extractInlineOverrideState(inlineOverride, row);
previewButton.dataset.previewText = pronInput?.value || token; syncInlinePreviewUI(row, inlineOverride, inlineState, previewButton);
previewButton.dataset.voice = voiceSelect?.value || baseVoice || "";
previewButton.dataset.language = languageCode;
previewButton.dataset.speed = defaultSpeed;
previewButton.dataset.useGpu = useGpuDefault;
}
highlightMode = "inline"; highlightMode = "inline";
highlightedOverrideId = row.dataset.overrideId || ""; highlightedOverrideId = row.dataset.overrideId || "";
@@ -2182,22 +2315,9 @@ const initPrepare = (root = document) => {
if (!inlineOverride || !row) { if (!inlineOverride || !row) {
return; return;
} }
const pronInput = inlineOverride.querySelector('[data-role="manual-override-pronunciation"]'); const inlineState = extractInlineOverrideState(inlineOverride, row);
const voiceSelect = inlineOverride.querySelector('[data-role="manual-override-voice"]'); syncInlinePreviewUI(row, inlineOverride, inlineState);
const token = inlineOverride.dataset.pendingToken || row.querySelector('[data-role="entity-label"]')?.textContent || ""; void persistInlineOverrideState(inlineState);
const normalized = inlineOverride.dataset.pendingNormalized || row.dataset.normalized || token;
const context = inlineOverride.dataset.pendingContext || "";
highlightMode = "inline";
createOverrideFromData({
token,
normalized,
context,
pronunciation: pronInput?.value || "",
voice: voiceSelect?.value || "",
category: inlineOverride.dataset.pendingCategory || row.dataset.entityCategory || "",
kind: inlineOverride.dataset.pendingKind || row.dataset.entityKind || "",
source: "entity-inline",
});
return; return;
} }
@@ -2351,6 +2471,15 @@ const initPrepare = (root = document) => {
form.addEventListener("click", (event) => { form.addEventListener("click", (event) => {
const previewFromOverride = event.target.closest('[data-role="speaker-preview"]'); const previewFromOverride = event.target.closest('[data-role="speaker-preview"]');
if (previewFromOverride) {
const entityRow = previewFromOverride.closest('[data-role="entity-row"]');
const inlineOverride = entityRow?.querySelector('[data-role="inline-override"]');
if (inlineOverride && !inlineOverride.hidden) {
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
syncInlinePreviewUI(entityRow, inlineOverride, inlineState, previewFromOverride);
void persistInlineOverrideState(inlineState);
}
}
if (previewFromOverride?.dataset.overrideId) { if (previewFromOverride?.dataset.overrideId) {
void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId }); void flushManualOverrides({ overrideId: previewFromOverride.dataset.overrideId });
} }
@@ -2439,6 +2568,32 @@ const initPrepare = (root = document) => {
select.dataset.prevManual = select.value || ""; select.dataset.prevManual = select.value || "";
}); });
form.addEventListener("input", (event) => {
const inlineOverride = event.target.closest('[data-role="inline-override"]');
if (!inlineOverride || inlineOverride.hidden) {
return;
}
const entityRow = inlineOverride.closest('[data-role="entity-row"]');
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
syncInlinePreviewUI(entityRow, inlineOverride, inlineState);
if (inlineState) {
setManualOverrideStatus("Unsaved changes", "pending");
}
});
form.addEventListener("change", (event) => {
const inlineOverride = event.target.closest('[data-role="inline-override"]');
if (!inlineOverride || inlineOverride.hidden) {
return;
}
const entityRow = inlineOverride.closest('[data-role="entity-row"]');
const inlineState = extractInlineOverrideState(inlineOverride, entityRow);
syncInlinePreviewUI(entityRow, inlineOverride, inlineState);
if (inlineState) {
setManualOverrideStatus("Unsaved changes", "pending");
}
});
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
if (!form.contains(event.target)) { if (!form.contains(event.target)) {
hideGenderMenus(); hideGenderMenus();
+47
View File
@@ -1816,10 +1816,47 @@ button.step-indicator__item:focus-visible {
line-height: 1.55; line-height: 1.55;
} }
.manual-overrides__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.manual-overrides__copy {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.manual-overrides__header h2 { .manual-overrides__header h2 {
margin: 0; margin: 0;
} }
.manual-overrides__header-actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.manual-overrides__status {
font-size: 0.85rem;
color: var(--muted);
min-height: 1.2em;
}
.manual-overrides__status[data-state="success"] {
color: var(--success);
}
.manual-overrides__status[data-state="error"] {
color: var(--danger);
}
.manual-overrides__status[data-state="pending"] {
color: var(--accent);
}
.manual-overrides__search { .manual-overrides__search {
display: grid; display: grid;
gap: 1.25rem; gap: 1.25rem;
@@ -1849,6 +1886,16 @@ button.step-indicator__item:focus-visible {
grid-column: span 1; grid-column: span 1;
} }
.manual-overrides__header {
flex-direction: column;
align-items: stretch;
}
.manual-overrides__header-actions {
justify-content: flex-start;
flex-wrap: wrap;
}
.entity-tabs__nav { .entity-tabs__nav {
flex-direction: column; flex-direction: column;
padding: 0.35rem; padding: 0.35rem;
@@ -21,6 +21,33 @@
data-entities-enabled="{{ 'true' if recognition_enabled else 'false' }}"> data-entities-enabled="{{ 'true' if recognition_enabled else 'false' }}">
<input type="hidden" name="active_step" value="entities" data-role="active-step-input"> <input type="hidden" name="active_step" value="entities" data-role="active-step-input">
<div class="wizard-hidden-inputs" aria-hidden="true">
<input type="hidden" name="chunk_level" value="{{ pending.chunk_level }}">
<input type="hidden" name="speaker_analysis_threshold" value="{{ pending.speaker_analysis_threshold }}">
<input type="hidden" name="chapter_intro_delay" value="{{ '%.2f'|format(pending.chapter_intro_delay) }}">
{% if pending.generate_epub3 %}
<input type="hidden" name="generate_epub3" value="true">
{% endif %}
{% for chapter in pending.chapters %}
{% set idx = loop.index0 %}
{% set is_enabled = chapter.enabled is not defined or chapter.enabled %}
{% set selected_option = '__default' %}
{% if chapter.voice_profile %}
{% set selected_option = 'profile:' ~ chapter.voice_profile %}
{% elif chapter.voice %}
{% set selected_option = 'voice:' ~ chapter.voice %}
{% elif chapter.voice_formula %}
{% set selected_option = 'formula' %}
{% endif %}
{% if is_enabled %}
<input type="hidden" name="chapter-{{ idx }}-enabled" value="on">
{% endif %}
<input type="hidden" name="chapter-{{ idx }}-title" value="{{ chapter.title }}">
<input type="hidden" name="chapter-{{ idx }}-voice" value="{{ selected_option }}">
<input type="hidden" name="chapter-{{ idx }}-formula" value="{{ chapter.voice_formula or '' }}">
{% endfor %}
</div>
<div class="modal__body wizard-card__body"> <div class="modal__body wizard-card__body">
{% if error %} {% if error %}
<div class="alert alert--error">{{ error }}</div> <div class="alert alert--error">{{ error }}</div>
@@ -444,8 +471,14 @@
hidden> hidden>
<div class="manual-overrides" data-role="manual-overrides"> <div class="manual-overrides" data-role="manual-overrides">
<header class="manual-overrides__header"> <header class="manual-overrides__header">
<h2>Manual overrides</h2> <div class="manual-overrides__copy">
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p> <h2>Manual overrides</h2>
<p class="hint">Search tokens from the book or add custom entries. Set pronunciations and assign voices to ensure previews and conversions use your preferred delivery.</p>
</div>
<div class="manual-overrides__header-actions">
<span class="manual-overrides__status" data-role="manual-override-status" aria-live="polite"></span>
<button type="button" class="button button--ghost button--small" data-role="manual-override-save-all">Save overrides</button>
</div>
</header> </header>
<div class="manual-overrides__search"> <div class="manual-overrides__search">
<label class="field" for="manual-override-query"> <label class="field" for="manual-override-query">