mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 21:50:28 +02:00
feat: Refactor reader modal implementation and enhance dashboard event handling
This commit is contained in:
@@ -1,36 +1,13 @@
|
||||
import { initReaderUI } from "./reader.js";
|
||||
|
||||
const initDashboard = () => {
|
||||
const uploadModal = document.querySelector('[data-role="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 sourceFileInput = scope.querySelector('#source_file');
|
||||
const dropzone = document.querySelector('[data-role="upload-dropzone"]');
|
||||
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 element = document.getElementById(id);
|
||||
if (!element) return null;
|
||||
@@ -121,7 +98,6 @@ const initDashboard = () => {
|
||||
};
|
||||
|
||||
let lastTrigger = null;
|
||||
let readerTrigger = null;
|
||||
let previewAbortController = null;
|
||||
let previewObjectUrl = null;
|
||||
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) => {
|
||||
button.addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
@@ -221,55 +149,10 @@ const initDashboard = () => {
|
||||
closeUploadModal();
|
||||
return;
|
||||
}
|
||||
if (readerModal && !readerModal.hidden) {
|
||||
closeReaderModal();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const resolveEventMatch = (event, selector) => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
initReaderUI({ onBeforeOpen: closeUploadModal });
|
||||
|
||||
if (sourceFileInput) {
|
||||
sourceFileInput.addEventListener("change", updateDropzoneFilename);
|
||||
@@ -655,17 +538,6 @@ const initDashboard = () => {
|
||||
cancelPreviewRequest();
|
||||
stopPreviewAudio();
|
||||
});
|
||||
|
||||
bindReaderButtons();
|
||||
|
||||
document.addEventListener("htmx:afterSwap", (event) => {
|
||||
const fragment = event?.detail?.target;
|
||||
if (fragment instanceof Element) {
|
||||
bindReaderButtons(fragment);
|
||||
} else {
|
||||
bindReaderButtons();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -355,22 +355,18 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
wizard.dataset.speakerStep = skipSpeakers ? "disabled" : "enabled";
|
||||
}
|
||||
if (speakerIndicator) {
|
||||
speakerIndicator.hidden = skipSpeakers;
|
||||
if (skipSpeakers) {
|
||||
speakerIndicator.hidden = true;
|
||||
speakerIndicator.setAttribute("aria-hidden", "true");
|
||||
speakerIndicator.style.display = "none";
|
||||
} else {
|
||||
speakerIndicator.hidden = false;
|
||||
speakerIndicator.removeAttribute("aria-hidden");
|
||||
speakerIndicator.style.removeProperty("display");
|
||||
}
|
||||
}
|
||||
if (!skipSpeakers) {
|
||||
unlockStep("speakers");
|
||||
}
|
||||
if (speakerPanel) {
|
||||
if (skipSpeakers) {
|
||||
speakerPanel.style.display = "none";
|
||||
} else {
|
||||
speakerPanel.style.removeProperty("display");
|
||||
}
|
||||
speakerPanel.dataset.speakerEnabled = skipSpeakers ? "false" : "true";
|
||||
}
|
||||
if (continueButton) {
|
||||
if (skipSpeakers) {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { initReaderUI } from "./reader.js";
|
||||
|
||||
const initQueuePage = () => {
|
||||
initReaderUI();
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", initQueuePage, { once: true });
|
||||
} else {
|
||||
initQueuePage();
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
};
|
||||
@@ -175,25 +175,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 & 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>
|
||||
{% include "partials/reader_modal.html" %}
|
||||
|
||||
{% 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 & 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>
|
||||
@@ -10,6 +10,7 @@
|
||||
{% endif %}
|
||||
{% set is_chapters = wizard_step == 'chapters' %}
|
||||
{% 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' }}">
|
||||
<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">
|
||||
@@ -20,7 +21,7 @@
|
||||
<span class="step-indicator__index">2</span>
|
||||
<span class="step-indicator__label">Chapters</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__label">Speakers</span>
|
||||
</span>
|
||||
@@ -170,7 +171,11 @@
|
||||
</div>
|
||||
</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">
|
||||
<h2>Step 3 · Select speakers</h2>
|
||||
<p class="hint">Assign voices, audition samples, and finalize your roster.</p>
|
||||
|
||||
@@ -4,20 +4,6 @@
|
||||
|
||||
{% block content %}
|
||||
<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"
|
||||
hx-get="{{ url_for('web.jobs_partial') }}"
|
||||
hx-trigger="load, every 3s"
|
||||
@@ -26,4 +12,10 @@
|
||||
{{ jobs_panel|safe }}
|
||||
</div>
|
||||
</section>
|
||||
{% include "partials/reader_modal.html" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
{{ super() }}
|
||||
<script type="module" src="{{ url_for('static', filename='queue.js') }}"></script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user