feat: Implement upload modal event dispatching and enhance wizard modal navigation

This commit is contained in:
JB
2025-10-11 12:29:52 -07:00
parent 6266819670
commit 610091c0d9
6 changed files with 302 additions and 128 deletions
+11
View File
@@ -102,6 +102,15 @@ const initDashboard = () => {
let previewObjectUrl = null; let previewObjectUrl = null;
let suppressPauseStatus = false; let suppressPauseStatus = false;
const dispatchUploadModalEvent = (type, detail = {}) => {
const eventName = `upload-modal:${type}`;
if (uploadModal) {
uploadModal.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true }));
return;
}
document.dispatchEvent(new CustomEvent(eventName, { detail }));
};
const openUploadModal = (trigger) => { const openUploadModal = (trigger) => {
if (!uploadModal) return; if (!uploadModal) return;
lastTrigger = trigger || null; lastTrigger = trigger || null;
@@ -112,6 +121,7 @@ const initDashboard = () => {
if (focusTarget instanceof HTMLElement) { if (focusTarget instanceof HTMLElement) {
focusTarget.focus({ preventScroll: true }); focusTarget.focus({ preventScroll: true });
} }
dispatchUploadModalEvent("open", { trigger: lastTrigger });
}; };
const closeUploadModal = () => { const closeUploadModal = () => {
@@ -124,6 +134,7 @@ const initDashboard = () => {
if (lastTrigger && lastTrigger instanceof HTMLElement) { if (lastTrigger && lastTrigger instanceof HTMLElement) {
lastTrigger.focus({ preventScroll: true }); lastTrigger.focus({ preventScroll: true });
} }
dispatchUploadModalEvent("close", { trigger: lastTrigger });
}; };
openModalButtons.forEach((button) => { openModalButtons.forEach((button) => {
+94 -8
View File
@@ -2,6 +2,55 @@ document.addEventListener("DOMContentLoaded", () => {
const form = document.querySelector(".prepare-form"); const form = document.querySelector(".prepare-form");
if (!form) return; if (!form) return;
const wizardModal = document.querySelector('[data-role="wizard-modal"]');
const uploadModal = document.querySelector('[data-role="upload-modal"]');
const wizardPreviousButtons = Array.from(document.querySelectorAll('[data-role="wizard-previous"]'));
const openUploadTriggers = Array.from(document.querySelectorAll('[data-role="open-upload-modal"]'));
const showWizardModal = () => {
if (!wizardModal) return;
wizardModal.hidden = false;
wizardModal.dataset.open = "true";
wizardModal.removeAttribute("aria-hidden");
document.body.classList.add("modal-open");
};
const hideWizardModal = () => {
if (!wizardModal) return;
wizardModal.hidden = true;
delete wizardModal.dataset.open;
wizardModal.setAttribute("aria-hidden", "true");
};
const triggerUploadModal = () => {
const existingTrigger = openUploadTriggers.find((button) => button !== null);
if (existingTrigger) {
existingTrigger.dispatchEvent(new MouseEvent("click", { bubbles: true }));
return;
}
if (!uploadModal) return;
uploadModal.hidden = false;
uploadModal.dataset.open = "true";
document.body.classList.add("modal-open");
const focusTarget = uploadModal.querySelector("#source_file") || uploadModal.querySelector("#source_text") || uploadModal;
if (focusTarget instanceof HTMLElement) {
focusTarget.focus({ preventScroll: true });
}
};
showWizardModal();
document.addEventListener("upload-modal:open", hideWizardModal);
document.addEventListener("upload-modal:close", showWizardModal);
wizardPreviousButtons.forEach((button) => {
button.addEventListener("click", (event) => {
event.preventDefault();
hideWizardModal();
triggerUploadModal();
});
});
const parseJSONScript = (id) => { const parseJSONScript = (id) => {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return null; if (!el) return null;
@@ -184,12 +233,39 @@ document.addEventListener("DOMContentLoaded", () => {
const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]")); const chapterRows = Array.from(form.querySelectorAll("[data-role=chapter-row]"));
const setRowExpansion = (row, expanded) => {
if (!row) return;
const details = row.querySelector('[data-role="chapter-details"]');
const toggle = row.querySelector('[data-role="chapter-toggle"]');
const isExpanded = Boolean(expanded);
row.dataset.expanded = isExpanded ? "true" : "false";
if (details) {
details.hidden = !isExpanded;
details.setAttribute("aria-hidden", isExpanded ? "false" : "true");
}
if (toggle) {
toggle.setAttribute("aria-expanded", isExpanded ? "true" : "false");
toggle.setAttribute("aria-label", isExpanded ? "Hide chapter details" : "Show chapter details");
}
};
const toggleRowExpansion = (row, force) => {
if (!row) return;
const current = row.dataset.expanded === "true";
const next = typeof force === "boolean" ? force : !current;
setRowExpansion(row, next);
};
const isRowEnabled = (row) => {
const checkbox = row?.querySelector('[data-role="chapter-enabled"]');
return checkbox ? checkbox.checked : true;
};
const updateRowState = (row) => { const updateRowState = (row) => {
const enabled = row.querySelector('[data-role=chapter-enabled]'); const enabled = row.querySelector('[data-role=chapter-enabled]');
const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea")); const inputs = Array.from(row.querySelectorAll("input[type=text], select, textarea"));
const warning = row.querySelector('[data-role=chapter-warning]'); const warning = row.querySelector('[data-role=chapter-warning]');
const snippet = row.querySelector('[data-role="chapter-snippet"]'); const toggle = row.querySelector('[data-role="chapter-toggle"]');
const details = row.querySelector('[data-role="chapter-details"]');
const isChecked = enabled ? enabled.checked : true; const isChecked = enabled ? enabled.checked : true;
row.dataset.disabled = isChecked ? "false" : "true"; row.dataset.disabled = isChecked ? "false" : "true";
@@ -214,14 +290,13 @@ document.addEventListener("DOMContentLoaded", () => {
const select = row.querySelector("select[data-role=voice-select]"); const select = row.querySelector("select[data-role=voice-select]");
toggleFormula(select); toggleFormula(select);
if (snippet) { if (!isChecked) {
snippet.hidden = !isChecked; setRowExpansion(row, false);
snippet.setAttribute("aria-hidden", isChecked ? "false" : "true");
} }
if (details) { if (toggle) {
details.hidden = !isChecked; toggle.disabled = !isChecked;
details.setAttribute("aria-hidden", isChecked ? "false" : "true"); toggle.setAttribute("aria-disabled", isChecked ? "false" : "true");
} }
if (warning) { if (warning) {
@@ -248,6 +323,7 @@ document.addEventListener("DOMContentLoaded", () => {
}; };
chapterRows.forEach((row) => { chapterRows.forEach((row) => {
setRowExpansion(row, row.dataset.expanded === "true");
const enabled = row.querySelector('[data-role=chapter-enabled]'); const enabled = row.querySelector('[data-role=chapter-enabled]');
if (enabled) { if (enabled) {
enabled.addEventListener("change", () => updateRowState(row)); enabled.addEventListener("change", () => updateRowState(row));
@@ -258,6 +334,16 @@ document.addEventListener("DOMContentLoaded", () => {
select.addEventListener("change", () => toggleFormula(select)); select.addEventListener("change", () => toggleFormula(select));
toggleFormula(select); toggleFormula(select);
} }
const toggleButton = row.querySelector('[data-role="chapter-toggle"]');
if (toggleButton) {
toggleButton.addEventListener("click", () => {
if (!isRowEnabled(row)) {
setRowExpansion(row, false);
return;
}
toggleRowExpansion(row);
});
}
}); });
const updatePreviewVoice = (select) => { const updatePreviewVoice = (select) => {
+63 -1
View File
@@ -521,6 +521,11 @@ body {
padding: 3rem 0 5rem; padding: 3rem 0 5rem;
} }
.wizard-page--modal {
padding: 0;
min-height: 100vh;
}
.wizard-card { .wizard-card {
width: min(1100px, calc(100% - 2.5rem)); width: min(1100px, calc(100% - 2.5rem));
margin: 0 auto; margin: 0 auto;
@@ -1789,13 +1794,70 @@ button.step-indicator__item:focus-visible {
opacity: 0.5; opacity: 0.5;
} }
.chapter-card__header { .chapter-card__summary {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: flex-start; align-items: flex-start;
gap: 1.25rem; gap: 1.25rem;
} }
.chapter-card__summary:focus-within {
outline: none;
}
.chapter-card__toggle {
border: none;
background: rgba(148, 163, 184, 0.1);
color: var(--muted);
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: background 0.2s ease, color 0.2s ease, transform 0.2s ease;
}
.chapter-card__toggle:hover,
.chapter-card__toggle:focus-visible {
background: rgba(148, 163, 184, 0.2);
color: #fff;
}
.chapter-card__toggle[disabled] {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.chapter-card__toggle:focus-visible {
outline: 2px solid rgba(56, 189, 248, 0.45);
outline-offset: 2px;
}
.chapter-card__toggle-icon {
display: inline-flex;
font-size: 1rem;
transition: transform 0.2s ease;
}
.chapter-card[data-expanded="true"] .chapter-card__toggle-icon {
transform: rotate(180deg);
}
.chapter-card__details {
display: grid;
gap: 1rem;
}
.chapter-card__snippet {
margin: 0;
font-size: 0.95rem;
line-height: 1.5;
color: var(--muted);
}
.chapter-card__title { .chapter-card__title {
font-weight: 600; font-weight: 600;
display: flex; display: flex;
@@ -8,6 +8,7 @@
{% elif settings_dict %} {% elif settings_dict %}
{% set is_multi = settings_dict.get('speaker_mode', 'single') == 'multi' %} {% set is_multi = settings_dict.get('speaker_mode', 'single') == 'multi' %}
{% endif %} {% endif %}
{% set total_steps = 3 if is_multi else 2 %}
{% set language_value = pending.language if pending else settings_dict.get('language', '') %} {% set language_value = pending.language if pending else settings_dict.get('language', '') %}
{% if not language_value %} {% if not language_value %}
{% set sorted_languages = options.languages|dictsort %} {% set sorted_languages = options.languages|dictsort %}
@@ -65,7 +66,7 @@
</span> </span>
{% endif %} {% endif %}
</nav> </nav>
<p class="modal__eyebrow">Step 1 of 3</p> <p class="modal__eyebrow">Step 1 of {{ total_steps }}</p>
<h2 class="modal__title" id="upload-modal-title">Upload &amp; settings</h2> <h2 class="modal__title" id="upload-modal-title">Upload &amp; settings</h2>
<p class="hint">Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.</p> <p class="hint">Choose your source file or paste text, then set the defaults used for chapter analysis and speaker casting.</p>
</div> </div>
+23 -13
View File
@@ -3,10 +3,13 @@
{% block title %}Prepare · {{ pending.original_filename }}{% endblock %} {% block title %}Prepare · {{ pending.original_filename }}{% endblock %}
{% set is_multi_speaker = pending.speaker_mode == 'multi' %} {% set is_multi_speaker = pending.speaker_mode == 'multi' %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %} {% block content %}
<section class="wizard-page" data-step="chapters"> <section class="wizard-page wizard-page--modal" data-step="chapters">
<div class="wizard-card card card--modal"> <div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-chapters-title">
<header class="modal__header wizard-card__header"> <header class="modal__header wizard-card__header">
<div class="wizard-card__headline"> <div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow"> <nav class="step-indicator" aria-label="Audiobook workflow">
@@ -30,7 +33,8 @@
</a> </a>
{% endif %} {% endif %}
</nav> </nav>
<h2 class="modal__title">Select chapters</h2> <p class="modal__eyebrow">Step 2 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-chapters-title">Select chapters</h2>
<p class="hint">Choose which chapters to convert. We'll analyse speakers automatically when you continue.</p> <p class="hint">Choose which chapters to convert. We'll analyse speakers automatically when you continue.</p>
</div> </div>
<div class="wizard-card__aside"> <div class="wizard-card__aside">
@@ -83,8 +87,8 @@
<article class="chapter-card" <article class="chapter-card"
data-role="chapter-row" data-role="chapter-row"
data-disabled="{{ 'false' if is_enabled else 'true' }}" data-disabled="{{ 'false' if is_enabled else 'true' }}"
data-expanded="{{ 'true' if is_enabled else 'false' }}"> data-expanded="false">
<header class="chapter-card__summary"> <header class="chapter-card__summary" data-role="chapter-summary">
<label class="chapter-card__checkbox"> <label class="chapter-card__checkbox">
<input type="checkbox" <input type="checkbox"
name="chapter-{{ loop.index0 }}-enabled" name="chapter-{{ loop.index0 }}-enabled"
@@ -92,15 +96,20 @@
{% if is_enabled %}checked{% endif %}> {% if is_enabled %}checked{% endif %}>
<span>Chapter {{ loop.index }} · {{ chapter.title }}</span> <span>Chapter {{ loop.index }} · {{ chapter.title }}</span>
</label> </label>
<p class="chapter-card__snippet" <button type="button"
data-role="chapter-snippet" class="chapter-card__toggle"
{% if not is_enabled %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}> data-role="chapter-toggle"
{{ excerpt }}{% if raw_excerpt|length > 240 %}…{% endif %} aria-expanded="false"
</p> aria-label="Toggle chapter details">
<span class="chapter-card__toggle-icon" aria-hidden="true">&#9662;</span>
</button>
</header> </header>
<div class="chapter-card__details" <div class="chapter-card__details"
data-role="chapter-details" data-role="chapter-details">
{% if not is_enabled %}hidden aria-hidden="true"{% else %}aria-hidden="false"{% endif %}> <p class="chapter-card__snippet"
data-role="chapter-snippet">
{{ excerpt }}{% if raw_excerpt|length > 240 %}…{% endif %}
</p>
<div class="chapter-card__field"> <div class="chapter-card__field">
<label for="chapter-{{ loop.index0 }}-title">Title</label> <label for="chapter-{{ loop.index0 }}-title">Title</label>
<input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}"> <input type="text" id="chapter-{{ loop.index0 }}-title" name="chapter-{{ loop.index0 }}-title" value="{{ chapter.title }}">
@@ -148,7 +157,7 @@
</div> </div>
<footer class="modal__footer wizard-card__footer"> <footer class="modal__footer wizard-card__footer">
<div class="wizard-card__footer-actions"> <div class="wizard-card__footer-actions">
<a class="button button--ghost" href="{{ url_for('web.index') }}">Previous</a> <button type="button" class="button button--ghost" data-role="wizard-previous">Previous</button>
<button type="submit" class="button button--ghost" form="cancel-form">Cancel</button> <button type="submit" class="button button--ghost" form="cancel-form">Cancel</button>
</div> </div>
<div class="wizard-card__footer-actions"> <div class="wizard-card__footer-actions">
@@ -170,6 +179,7 @@
</form> </form>
<form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form> <form method="post" action="{{ url_for('web.cancel_pending_job', pending_id=pending.id) }}" id="cancel-form"></form>
</div> </div>
</div>
</section> </section>
{% with pending=pending, readonly=True, active_step='chapters' %} {% with pending=pending, readonly=True, active_step='chapters' %}
{% include "partials/upload_modal.html" %} {% include "partials/upload_modal.html" %}
+7 -3
View File
@@ -7,10 +7,13 @@
{% set analysis_speakers = analysis.get('speakers', {}) %} {% set analysis_speakers = analysis.get('speakers', {}) %}
{% set speaker_configs = options.speaker_configs or [] %} {% set speaker_configs = options.speaker_configs or [] %}
{% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %} {% set recommended_template = options.speaker_pronunciation_sentence or "This is {{name}} speaking." %}
{% set total_steps = 3 if is_multi_speaker else 2 %}
{% block content %} {% block content %}
<section class="wizard-page" data-step="speakers"> <section class="wizard-page wizard-page--modal" data-step="speakers">
<div class="wizard-card card card--modal"> <div class="modal modal--wizard" data-role="wizard-modal" data-open="true">
<div class="modal__overlay" aria-hidden="true"></div>
<div class="modal__content card card--modal wizard-card" role="dialog" aria-modal="true" aria-labelledby="prepare-speakers-title">
<header class="modal__header wizard-card__header"> <header class="modal__header wizard-card__header">
<div class="wizard-card__headline"> <div class="wizard-card__headline">
<nav class="step-indicator" aria-label="Audiobook workflow"> <nav class="step-indicator" aria-label="Audiobook workflow">
@@ -34,7 +37,8 @@
</a> </a>
{% endif %} {% endif %}
</nav> </nav>
<h2 class="modal__title">Select speakers</h2> <p class="modal__eyebrow">Step 3 of {{ total_steps }}</p>
<h2 class="modal__title" id="prepare-speakers-title">Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p> <p class="hint">Assign voices, audition samples, and finalise your roster before queueing the conversion.</p>
</div> </div>
<div class="wizard-card__aside"> <div class="wizard-card__aside">