feat: Refactor reader modal implementation and enhance dashboard event handling

This commit is contained in:
JB
2025-10-11 07:13:59 -07:00
parent 5be4da1cce
commit 97fd8b85fc
8 changed files with 263 additions and 175 deletions
+3 -131
View File
@@ -1,36 +1,13 @@
import { initReaderUI } from "./reader.js";
const initDashboard = () => { const initDashboard = () => {
const uploadModal = document.querySelector('[data-role="upload-modal"]'); const uploadModal = document.querySelector('[data-role="upload-modal"]');
const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]'); const openModalButtons = document.querySelectorAll('[data-role="open-upload-modal"]');
const readerModal = document.querySelector('[data-role="reader-modal"]');
const readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null;
const readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null;
const readerTitle = readerModal?.querySelector('#reader-modal-title') || null;
const defaultReaderHint = readerHint?.textContent || "";
const scope = uploadModal || document; const scope = uploadModal || document;
const sourceFileInput = scope.querySelector('#source_file'); const sourceFileInput = scope.querySelector('#source_file');
const dropzone = document.querySelector('[data-role="upload-dropzone"]'); const dropzone = document.querySelector('[data-role="upload-dropzone"]');
const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]'); const dropzoneFilename = document.querySelector('[data-role="upload-dropzone-filename"]');
const readerButtonRegistry = new WeakSet();
const bindReaderButtons = (root) => {
const context = root instanceof Element ? root : document;
const buttons = context.querySelectorAll('[data-role="open-reader"]');
buttons.forEach((button) => {
if (!(button instanceof HTMLElement)) {
return;
}
if (readerButtonRegistry.has(button)) {
return;
}
button.addEventListener("click", (event) => {
event.preventDefault();
openReaderModal(button);
});
readerButtonRegistry.add(button);
});
};
const parseJSONScript = (id) => { const parseJSONScript = (id) => {
const element = document.getElementById(id); const element = document.getElementById(id);
if (!element) return null; if (!element) return null;
@@ -121,7 +98,6 @@ const initDashboard = () => {
}; };
let lastTrigger = null; let lastTrigger = null;
let readerTrigger = null;
let previewAbortController = null; let previewAbortController = null;
let previewObjectUrl = null; let previewObjectUrl = null;
let suppressPauseStatus = false; let suppressPauseStatus = false;
@@ -150,54 +126,6 @@ const initDashboard = () => {
} }
}; };
const openReaderModal = (trigger) => {
const url = trigger?.dataset.readerUrl || "";
if (!url) return;
if (!readerModal || !readerFrame) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
readerTrigger = trigger || null;
const bookTitle = trigger?.dataset.bookTitle || "";
if (readerTitle) {
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
}
if (readerHint) {
readerHint.textContent = bookTitle ? `Preview ${bookTitle} directly in your browser.` : defaultReaderHint;
}
closeUploadModal();
readerModal.hidden = false;
readerModal.dataset.open = "true";
document.body.classList.add("modal-open");
readerFrame.src = url;
try {
readerFrame.focus({ preventScroll: true });
} catch (error) {
// Ignore focus errors when the browser blocks iframe focus
}
};
const closeReaderModal = () => {
if (!readerModal) return;
if (readerModal.hidden) return;
readerModal.hidden = true;
delete readerModal.dataset.open;
document.body.classList.remove("modal-open");
if (readerFrame) {
readerFrame.src = "about:blank";
}
if (readerHint) {
readerHint.textContent = defaultReaderHint;
}
if (readerTitle) {
readerTitle.textContent = "Read & listen";
}
if (readerTrigger && readerTrigger instanceof HTMLElement) {
readerTrigger.focus({ preventScroll: true });
}
readerTrigger = null;
};
openModalButtons.forEach((button) => { openModalButtons.forEach((button) => {
button.addEventListener("click", (event) => { button.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
@@ -221,55 +149,10 @@ const initDashboard = () => {
closeUploadModal(); closeUploadModal();
return; return;
} }
if (readerModal && !readerModal.hidden) {
closeReaderModal();
}
} }
}); });
const resolveEventMatch = (event, selector) => { initReaderUI({ onBeforeOpen: closeUploadModal });
const target = event.target;
if (target instanceof Element) {
const match = target.closest(selector);
if (match) {
return match;
}
}
if (typeof event.composedPath === "function") {
const path = event.composedPath();
for (const node of path) {
if (node instanceof Element) {
if (node.matches(selector)) {
return node;
}
const match = node.closest(selector);
if (match) {
return match;
}
}
}
}
const fallback = target?.parentElement;
if (fallback instanceof Element) {
return fallback.closest(selector);
}
return null;
};
document.addEventListener("click", (event) => {
const readerClose = resolveEventMatch(event, '[data-role="reader-modal-close"]');
if (readerClose) {
event.preventDefault();
closeReaderModal();
return;
}
const readerTriggerBtn = resolveEventMatch(event, '[data-role="open-reader"]');
if (readerTriggerBtn instanceof HTMLElement) {
event.preventDefault();
openReaderModal(readerTriggerBtn);
}
});
if (sourceFileInput) { if (sourceFileInput) {
sourceFileInput.addEventListener("change", updateDropzoneFilename); sourceFileInput.addEventListener("change", updateDropzoneFilename);
@@ -655,17 +538,6 @@ const initDashboard = () => {
cancelPreviewRequest(); cancelPreviewRequest();
stopPreviewAudio(); stopPreviewAudio();
}); });
bindReaderButtons();
document.addEventListener("htmx:afterSwap", (event) => {
const fragment = event?.detail?.target;
if (fragment instanceof Element) {
bindReaderButtons(fragment);
} else {
bindReaderButtons();
}
});
}; };
if (document.readyState === "loading") { if (document.readyState === "loading") {
+5 -9
View File
@@ -355,22 +355,18 @@ document.addEventListener("DOMContentLoaded", () => {
wizard.dataset.speakerStep = skipSpeakers ? "disabled" : "enabled"; wizard.dataset.speakerStep = skipSpeakers ? "disabled" : "enabled";
} }
if (speakerIndicator) { if (speakerIndicator) {
speakerIndicator.hidden = skipSpeakers;
if (skipSpeakers) { if (skipSpeakers) {
speakerIndicator.hidden = true;
speakerIndicator.setAttribute("aria-hidden", "true"); speakerIndicator.setAttribute("aria-hidden", "true");
speakerIndicator.style.display = "none";
} else { } else {
speakerIndicator.hidden = false;
speakerIndicator.removeAttribute("aria-hidden"); speakerIndicator.removeAttribute("aria-hidden");
speakerIndicator.style.removeProperty("display");
} }
} }
if (!skipSpeakers) {
unlockStep("speakers");
}
if (speakerPanel) { if (speakerPanel) {
if (skipSpeakers) { speakerPanel.dataset.speakerEnabled = skipSpeakers ? "false" : "true";
speakerPanel.style.display = "none";
} else {
speakerPanel.style.removeProperty("display");
}
} }
if (continueButton) { if (continueButton) {
if (skipSpeakers) { if (skipSpeakers) {
+11
View File
@@ -0,0 +1,11 @@
import { initReaderUI } from "./reader.js";
const initQueuePage = () => {
initReaderUI();
};
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", initQueuePage, { once: true });
} else {
initQueuePage();
}
+211
View File
@@ -0,0 +1,211 @@
const readerButtonRegistry = new WeakSet();
let initialized = false;
let readerModal = null;
let readerFrame = null;
let readerHint = null;
let readerTitle = null;
let readerTrigger = null;
let defaultReaderHint = "";
const resolveEventMatch = (event, selector) => {
const target = event.target;
if (target instanceof Element) {
const match = target.closest(selector);
if (match) {
return match;
}
}
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
for (const node of path) {
if (node instanceof Element) {
if (node.matches(selector)) {
return node;
}
const match = node.closest(selector);
if (match) {
return match;
}
}
}
return null;
};
const closeReaderModal = () => {
if (!readerModal) {
return;
}
if (readerModal.hidden) {
return;
}
readerModal.hidden = true;
readerModal.removeAttribute("data-open");
document.body.classList.remove("modal-open");
if (readerFrame) {
readerFrame.src = "about:blank";
}
if (readerHint && defaultReaderHint) {
readerHint.textContent = defaultReaderHint;
}
if (readerTitle) {
readerTitle.textContent = "Read & listen";
}
if (readerTrigger instanceof HTMLElement) {
try {
readerTrigger.focus({ preventScroll: true });
} catch (error) {
// Ignore focus errors.
}
}
readerTrigger = null;
};
const createBindReaderButtons = (openReaderModal) => {
return (root) => {
const context = root instanceof Element ? root : document;
const buttons = context.querySelectorAll('[data-role="open-reader"]');
buttons.forEach((button) => {
if (!(button instanceof HTMLElement)) {
return;
}
if (readerButtonRegistry.has(button)) {
return;
}
button.addEventListener("click", (event) => {
event.preventDefault();
openReaderModal(button);
});
readerButtonRegistry.add(button);
});
};
};
export const initReaderUI = (options = {}) => {
if (initialized) {
return {
bindReaderButtons: createBindReaderButtons((trigger) => {
if (typeof options.onBeforeOpen === "function") {
options.onBeforeOpen();
}
openReader(trigger, options);
}),
closeReaderModal,
};
}
readerModal = document.querySelector('[data-role="reader-modal"]');
readerFrame = readerModal?.querySelector('[data-role="reader-frame"]') || null;
readerHint = readerModal?.querySelector('[data-role="reader-modal-hint"]') || null;
readerTitle = readerModal?.querySelector('#reader-modal-title') || null;
defaultReaderHint = readerHint?.textContent || "";
const openReaderModal = (trigger) => {
if (typeof options.onBeforeOpen === "function") {
options.onBeforeOpen();
}
if (!(trigger instanceof HTMLElement)) {
return;
}
const url = trigger.dataset.readerUrl || "";
if (!url) {
return;
}
if (!readerModal || !readerFrame) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
readerTrigger = trigger;
const bookTitle = trigger.dataset.bookTitle || "";
if (readerTitle) {
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
}
if (readerHint) {
readerHint.textContent = bookTitle
? `Preview ${bookTitle} directly in your browser.`
: defaultReaderHint;
}
readerModal.hidden = false;
readerModal.dataset.open = "true";
document.body.classList.add("modal-open");
readerFrame.src = url;
try {
readerFrame.focus({ preventScroll: true });
} catch (error) {
// Ignore focus errors.
}
};
const bindReaderButtons = createBindReaderButtons(openReaderModal);
bindReaderButtons();
document.addEventListener("click", (event) => {
const closeButton = resolveEventMatch(event, '[data-role="reader-modal-close"]');
if (closeButton) {
event.preventDefault();
closeReaderModal();
return;
}
const trigger = resolveEventMatch(event, '[data-role="open-reader"]');
if (trigger instanceof HTMLElement) {
event.preventDefault();
openReaderModal(trigger);
}
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape") {
closeReaderModal();
}
});
document.addEventListener("htmx:afterSwap", (event) => {
const fragment = event?.detail?.target;
if (fragment instanceof Element) {
bindReaderButtons(fragment);
} else {
bindReaderButtons();
}
});
initialized = true;
return {
bindReaderButtons,
closeReaderModal,
};
};
const openReader = (trigger, options) => {
if (typeof options.onBeforeOpen === "function") {
options.onBeforeOpen();
}
if (!(trigger instanceof HTMLElement)) {
return;
}
const url = trigger.dataset.readerUrl || "";
if (!url) {
return;
}
if (!readerModal || !readerFrame) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
readerTrigger = trigger;
const bookTitle = trigger.dataset.bookTitle || "";
if (readerTitle) {
readerTitle.textContent = bookTitle ? `${bookTitle} · reader` : "Read & listen";
}
if (readerHint) {
readerHint.textContent = bookTitle
? `Preview ${bookTitle} directly in your browser.`
: defaultReaderHint;
}
readerModal.hidden = false;
readerModal.dataset.open = "true";
document.body.classList.add("modal-open");
readerFrame.src = url;
try {
readerFrame.focus({ preventScroll: true });
} catch (error) {
// Ignore focus errors.
}
};
+1 -19
View File
@@ -175,25 +175,7 @@
</div> </div>
</div> </div>
<div class="modal" data-role="reader-modal" hidden> {% include "partials/reader_modal.html" %}
<div class="modal__overlay" data-role="reader-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal reader-modal" role="dialog" aria-modal="true" aria-labelledby="reader-modal-title">
<header class="modal__header">
<div class="modal__heading">
<h2 class="modal__title" id="reader-modal-title">Read &amp; listen</h2>
<p class="hint" data-role="reader-modal-hint">Preview synchronized narration directly in your browser.</p>
</div>
<button type="button" class="icon-button" data-role="reader-modal-close" aria-label="Close reader"></button>
</header>
<div class="modal__body reader-modal__body">
<iframe title="EPUB reader"
class="reader-modal__frame"
data-role="reader-frame"
allow="autoplay"
loading="lazy"></iframe>
</div>
</div>
</div>
{% endblock %} {% endblock %}
@@ -0,0 +1,19 @@
<div class="modal" data-role="reader-modal" hidden>
<div class="modal__overlay" data-role="reader-modal-close" tabindex="-1"></div>
<div class="modal__content card card--modal reader-modal" role="dialog" aria-modal="true" aria-labelledby="reader-modal-title">
<header class="modal__header">
<div class="modal__heading">
<h2 class="modal__title" id="reader-modal-title">Read &amp; listen</h2>
<p class="hint" data-role="reader-modal-hint">Preview synchronized narration directly in your browser.</p>
</div>
<button type="button" class="icon-button" data-role="reader-modal-close" aria-label="Close reader"></button>
</header>
<div class="modal__body reader-modal__body">
<iframe title="EPUB reader"
class="reader-modal__frame"
data-role="reader-frame"
allow="autoplay"
loading="lazy"></iframe>
</div>
</div>
</div>
+7 -2
View File
@@ -10,6 +10,7 @@
{% endif %} {% endif %}
{% set is_chapters = wizard_step == 'chapters' %} {% set is_chapters = wizard_step == 'chapters' %}
{% set is_speakers = wizard_step == 'speakers' %} {% set is_speakers = wizard_step == 'speakers' %}
{% set hide_speakers_panel = not is_speakers %}
<section class="card" data-role="prepare-wizard" data-initial-step="{{ wizard_step }}" data-speaker-step="{{ 'enabled' if is_multi_speaker else 'disabled' }}"> <section class="card" data-role="prepare-wizard" data-initial-step="{{ wizard_step }}" data-speaker-step="{{ 'enabled' if is_multi_speaker else 'disabled' }}">
<div class="step-indicator" aria-label="Audiobook workflow" data-role="wizard-indicator"> <div class="step-indicator" aria-label="Audiobook workflow" data-role="wizard-indicator">
<span class="step-indicator__item is-complete" data-role="wizard-step" data-step-key="setup"> <span class="step-indicator__item is-complete" data-role="wizard-step" data-step-key="setup">
@@ -20,7 +21,7 @@
<span class="step-indicator__index">2</span> <span class="step-indicator__index">2</span>
<span class="step-indicator__label">Chapters</span> <span class="step-indicator__label">Chapters</span>
</span> </span>
<span class="step-indicator__item{% if is_speakers %} is-active{% endif %}" data-role="wizard-step" data-step-key="speakers" {% if not is_multi_speaker %}hidden aria-hidden="true" style="display:none;"{% endif %}> <span class="step-indicator__item{% if is_speakers %} is-active{% endif %}" data-role="wizard-step" data-step-key="speakers" {% if not is_multi_speaker %}hidden aria-hidden="true"{% endif %}>
<span class="step-indicator__index">3</span> <span class="step-indicator__index">3</span>
<span class="step-indicator__label">Speakers</span> <span class="step-indicator__label">Speakers</span>
</span> </span>
@@ -170,7 +171,11 @@
</div> </div>
</section> </section>
<section class="prepare-step" data-step-panel="speakers" data-speaker-panel="true" {% if not is_multi_speaker %}style="display:none;"{% endif %} hidden> <section class="prepare-step"
data-step-panel="speakers"
data-speaker-panel="true"
{% if hide_speakers_panel %}hidden aria-hidden="true"{% endif %}
data-speaker-enabled="{{ 'true' if is_multi_speaker else 'false' }}">
<header class="prepare-step__header"> <header class="prepare-step__header">
<h2>Step 3 · Select speakers</h2> <h2>Step 3 · Select speakers</h2>
<p class="hint">Assign voices, audition samples, and finalize your roster.</p> <p class="hint">Assign voices, audition samples, and finalize your roster.</p>
+6 -14
View File
@@ -4,20 +4,6 @@
{% block content %} {% block content %}
<section class="card"> <section class="card">
<div class="step-indicator" aria-label="Audiobook workflow">
<span class="step-indicator__item is-complete">
<span class="step-indicator__index">1</span>
<span class="step-indicator__label">Upload</span>
</span>
<span class="step-indicator__item is-complete">
<span class="step-indicator__index">2</span>
<span class="step-indicator__label">Speakers</span>
</span>
<span class="step-indicator__item is-active">
<span class="step-indicator__index">3</span>
<span class="step-indicator__label">Queue</span>
</span>
</div>
<div id="jobs-panel" <div id="jobs-panel"
hx-get="{{ url_for('web.jobs_partial') }}" hx-get="{{ url_for('web.jobs_partial') }}"
hx-trigger="load, every 3s" hx-trigger="load, every 3s"
@@ -26,4 +12,10 @@
{{ jobs_panel|safe }} {{ jobs_panel|safe }}
</div> </div>
</section> </section>
{% include "partials/reader_modal.html" %}
{% endblock %}
{% block scripts %}
{{ super() }}
<script type="module" src="{{ url_for('static', filename='queue.js') }}"></script>
{% endblock %} {% endblock %}