From 2d61674e90a31f7359add5fb27522fbdefa76748 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 1 Nov 2025 06:49:43 -0700 Subject: [PATCH] feat: Implement folder browsing modal for Audiobookshelf integration and enhance folder selection UI --- abogen/web/static/settings.js | 267 ++++++++++++++++++++++++----- abogen/web/static/styles.css | 106 ++++++++++++ abogen/web/templates/settings.html | 32 +++- 3 files changed, 362 insertions(+), 43 deletions(-) diff --git a/abogen/web/static/settings.js b/abogen/web/static/settings.js index 1a526b2..827db2d 100644 --- a/abogen/web/static/settings.js +++ b/abogen/web/static/settings.js @@ -17,6 +17,17 @@ const outputAreas = { const normalizationAudio = document.querySelector('[data-role="normalization-preview-audio"]'); +const folderModal = document.querySelector('[data-role="audiobookshelf-folder-modal"]'); +const folderModalOverlay = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-overlay"]') : null; +const folderList = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-list"]') : null; +const folderStatusMessage = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-status"]') : null; +const folderFilter = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-filter"]') : null; +const folderEmptyState = folderModal ? folderModal.querySelector('[data-role="audiobookshelf-folder-empty"]') : null; +const defaultFolderEmptyMessage = folderEmptyState ? folderEmptyState.textContent : 'No folders match your filter.'; +let folderModalOpener = null; +let folderModalPreviousFocus = null; +let audiobookshelfFolderSource = []; + function setStatus(target, message, state) { if (!target) { return; @@ -93,6 +104,197 @@ function parseNumber(value, fallback) { return Number.isFinite(parsed) ? parsed : fallback; } +function normalizeFolderToken(value) { + return String(value || '').trim().toLowerCase(); +} + +function setFolderModalStatus(message, state) { + if (!folderStatusMessage) { + return; + } + folderStatusMessage.textContent = message || ''; + if (state) { + folderStatusMessage.dataset.state = state; + folderStatusMessage.hidden = false; + } else { + delete folderStatusMessage.dataset.state; + folderStatusMessage.hidden = !message; + } +} + +function clearFolderModalContents() { + if (folderList) { + folderList.innerHTML = ''; + } + if (folderEmptyState) { + folderEmptyState.textContent = defaultFolderEmptyMessage; + folderEmptyState.hidden = true; + } +} + +function openFolderModal(opener) { + if (!folderModal) { + return; + } + folderModalOpener = opener || null; + folderModalPreviousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + folderModal.hidden = false; + folderModal.dataset.open = 'true'; + document.body.classList.add('modal-open'); + if (folderFilter) { + folderFilter.value = ''; + folderFilter.disabled = true; + } + clearFolderModalContents(); + setFolderModalStatus('Loading folders...', 'loading'); +} + +function closeFolderModal(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (!folderModal || folderModal.hidden) { + return; + } + folderModal.dataset.open = 'false'; + folderModal.hidden = true; + document.body.classList.remove('modal-open'); + audiobookshelfFolderSource = []; + if (folderFilter) { + folderFilter.value = ''; + folderFilter.disabled = false; + } + clearFolderModalContents(); + setFolderModalStatus('', null); + const focusTarget = folderModalPreviousFocus && typeof folderModalPreviousFocus.focus === 'function' + ? folderModalPreviousFocus + : folderModalOpener; + if (focusTarget && typeof focusTarget.focus === 'function') { + focusTarget.focus({ preventScroll: false }); + } + folderModalPreviousFocus = null; + folderModalOpener = null; +} + +function renderFolderList(query) { + if (!folderList) { + return; + } + folderList.innerHTML = ''; + const normalizedQuery = normalizeFolderToken(query); + const matches = audiobookshelfFolderSource.filter((entry) => { + const tokens = [ + normalizeFolderToken(entry.name), + normalizeFolderToken(entry.path), + normalizeFolderToken(entry.id), + ]; + return !normalizedQuery || tokens.some((token) => token.includes(normalizedQuery)); + }); + if (!matches.length) { + if (folderEmptyState) { + folderEmptyState.textContent = normalizedQuery ? defaultFolderEmptyMessage : 'No folders found for this library.'; + folderEmptyState.hidden = false; + } + return; + } + if (folderEmptyState) { + folderEmptyState.textContent = defaultFolderEmptyMessage; + folderEmptyState.hidden = true; + } + matches.forEach((entry) => { + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'folder-picker__item'; + button.setAttribute('role', 'option'); + if (entry.id) { + button.dataset.folderId = entry.id; + } + const displayName = entry.name || entry.path || entry.id || 'Unnamed folder'; + const nameEl = document.createElement('span'); + nameEl.className = 'folder-picker__item-name'; + nameEl.textContent = displayName; + button.appendChild(nameEl); + if (entry.path && (!entry.name || entry.path.toLowerCase() !== entry.name.toLowerCase())) { + const pathEl = document.createElement('span'); + pathEl.className = 'folder-picker__item-path'; + pathEl.textContent = entry.path; + button.appendChild(pathEl); + } + if (entry.id) { + const idEl = document.createElement('span'); + idEl.className = 'folder-picker__item-id'; + idEl.textContent = entry.id; + button.appendChild(idEl); + } + button.addEventListener('click', () => handleFolderSelection(entry)); + folderList.appendChild(button); + }); +} + +function populateFolderPicker(entries) { + audiobookshelfFolderSource = Array.isArray(entries) ? entries : []; + if (!audiobookshelfFolderSource.length) { + if (folderFilter) { + folderFilter.value = ''; + folderFilter.disabled = true; + } + setFolderModalStatus('No folders found for this library.', 'info'); + if (folderEmptyState) { + folderEmptyState.textContent = 'No folders found for this library.'; + folderEmptyState.hidden = false; + } + return; + } + if (folderFilter) { + folderFilter.disabled = false; + folderFilter.value = ''; + folderFilter.focus({ preventScroll: true }); + } + setFolderModalStatus('', null); + if (folderEmptyState) { + folderEmptyState.textContent = defaultFolderEmptyMessage; + folderEmptyState.hidden = true; + } + renderFolderList(''); +} + +function handleFolderSelection(entry) { + const folderInput = form ? form.querySelector('#audiobookshelf_folder_id') : null; + if (folderInput) { + folderInput.value = entry.id || ''; + folderInput.dispatchEvent(new Event('input', { bubbles: true })); + } + closeFolderModal(); + const status = statusSelectors.audiobookshelf; + if (status) { + const label = entry.name || entry.path || entry.id || 'selected folder'; + setStatus(status, `Selected folder '${label}'.`, 'success'); + } +} + +function initFolderPicker() { + if (!folderModal) { + return; + } + const closeButtons = folderModal.querySelectorAll('[data-action="audiobookshelf-folder-close"]'); + closeButtons.forEach((button) => { + button.addEventListener('click', closeFolderModal); + }); + if (folderModalOverlay) { + folderModalOverlay.addEventListener('click', closeFolderModal); + } + if (folderFilter) { + folderFilter.addEventListener('input', () => renderFolderList(folderFilter.value)); + } + folderModal.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { + event.preventDefault(); + closeFolderModal(); + } + }); +} + function collectLLMFields() { const baseUrl = form.querySelector('#llm_base_url'); const apiKey = form.querySelector('#llm_api_key'); @@ -406,7 +608,7 @@ async function testAudiobookshelf(button) { } } -async function listAudiobookshelfFolders(button) { +async function browseAudiobookshelfFolders(button) { const status = statusSelectors.audiobookshelf; const fields = collectAudiobookshelfFields(); if (!status) { @@ -418,7 +620,11 @@ async function listAudiobookshelfFolders(button) { return; } clearStatus(status); - setStatus(status, 'Loading folders…'); + openFolderModal(button); + if (!folderModal) { + setStatus(status, 'Folder picker is unavailable in this view.', 'error'); + return; + } button.disabled = true; try { const response = await fetch('/api/integrations/audiobookshelf/folders', { @@ -431,49 +637,29 @@ async function listAudiobookshelfFolders(button) { throw new Error(payload.error || 'Folder lookup failed.'); } const folders = Array.isArray(payload.folders) ? payload.folders : []; + const modalActive = folderModal && !folderModal.hidden; if (!folders.length) { - setStatus(status, payload.message || 'No folders found for this library.', 'info'); + const message = payload.message || 'No folders found for this library.'; + setStatus(status, message, 'info'); + if (modalActive) { + clearFolderModalContents(); + setFolderModalStatus(message, 'info'); + } return; } - const list = folders - .map((entry, index) => { - const id = String(entry.id || '').trim(); - const name = String(entry.name || '').trim(); - const path = String(entry.path || '').trim(); - const descriptorParts = []; - if (name) { - descriptorParts.push(name); - } - if (path && (!name || path.toLowerCase() !== name.toLowerCase())) { - descriptorParts.push(path); - } - if (!descriptorParts.length && id) { - descriptorParts.push(id); - } - const descriptor = descriptorParts.join(' — ') || '(unnamed folder)'; - return `${index + 1}. ${descriptor}`; - }) - .join('\n'); - const choice = window.prompt(`Select a folder by number:\n\n${list}`, '1'); - if (choice === null) { - setStatus(status, 'Folder selection cancelled.', 'info'); + if (!modalActive) { + setStatus(status, 'Folders loaded.', 'info'); return; } - const selection = Number.parseInt(choice, 10); - if (!Number.isFinite(selection) || selection < 1 || selection > folders.length) { - setStatus(status, 'Enter a number from the list to select a folder.', 'error'); - return; - } - const selected = folders[selection - 1]; - const folderInput = form.querySelector('#audiobookshelf_folder_id'); - if (folderInput) { - folderInput.value = selected.id || ''; - folderInput.dispatchEvent(new Event('input', { bubbles: true })); - } - const label = String(selected.name || selected.path || selected.id || '').trim() || 'selected folder'; - setStatus(status, `Selected folder '${label}'.`, 'success'); + populateFolderPicker(folders); + setStatus(status, 'Choose a folder below.', 'info'); } catch (error) { - setStatus(status, error instanceof Error ? error.message : 'Folder lookup failed.', 'error'); + const message = error instanceof Error ? error.message : 'Folder lookup failed.'; + setStatus(status, message, 'error'); + if (folderModal && !folderModal.hidden) { + clearFolderModalContents(); + setFolderModalStatus(message, 'error'); + } } finally { button.disabled = false; } @@ -590,7 +776,7 @@ function initActions() { } const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]'); if (audiobookshelfBrowseButton) { - audiobookshelfBrowseButton.addEventListener('click', () => listAudiobookshelfFolders(audiobookshelfBrowseButton)); + audiobookshelfBrowseButton.addEventListener('click', () => browseAudiobookshelfFolders(audiobookshelfBrowseButton)); } } @@ -610,5 +796,6 @@ if (form) { initNavigation(); initSampleSelector(); initActions(); + initFolderPicker(); initLLMStateWatchers(); } diff --git a/abogen/web/static/styles.css b/abogen/web/static/styles.css index 418c1ed..a96c117 100644 --- a/abogen/web/static/styles.css +++ b/abogen/web/static/styles.css @@ -335,6 +335,100 @@ body { border-radius: 28px; } +.modal__content.folder-picker-modal { + width: min(600px, 95vw); +} + +.folder-picker { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.folder-picker__controls { + display: flex; + align-items: center; +} + +.folder-picker__controls input { + width: 100%; +} + +.folder-picker__status { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + +.folder-picker__status[data-state="loading"] { + color: var(--muted); +} + +.folder-picker__status[data-state="error"] { + color: var(--danger); +} + +.folder-picker__status[data-state="info"], +.folder-picker__status[data-state="success"] { + color: var(--accent); +} + +.folder-picker__list { + display: flex; + flex-direction: column; + gap: 0.65rem; + max-height: 20rem; + overflow-y: auto; + padding-right: 0.25rem; +} + +.folder-picker__item { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.25rem; + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.22); + border-radius: 16px; + background: rgba(15, 23, 42, 0.6); + padding: 0.8rem 1rem; + color: var(--text); + cursor: pointer; + transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease; +} + +.folder-picker__item:hover, +.folder-picker__item:focus-visible { + border-color: rgba(56, 189, 248, 0.5); + box-shadow: 0 0 0 4px rgba(56, 189, 248, 0.12); + outline: none; + transform: translateY(-1px); +} + +.folder-picker__item-name { + font-weight: 600; + font-size: 1rem; +} + +.folder-picker__item-path { + font-size: 0.85rem; + color: var(--muted); +} + +.folder-picker__item-id { + font-size: 0.75rem; + color: var(--muted); + background: rgba(148, 163, 184, 0.18); + padding: 0.15rem 0.4rem; + border-radius: 999px; +} + +.folder-picker__empty { + margin: 0; + font-size: 0.9rem; + color: var(--muted); +} + .reader-modal { width: min(960px, 95vw); height: min(90vh, 720px); @@ -1995,6 +2089,18 @@ button.step-indicator__item:focus-visible { gap: 0.35rem; } +.field__input-with-button { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.field__input-with-button > input { + flex: 1 1 16rem; + min-width: 12rem; +} + .preview-card { border: 1px solid rgba(148, 163, 184, 0.18); border-radius: 16px; diff --git a/abogen/web/templates/settings.html b/abogen/web/templates/settings.html index 4353f3b..8f1f59d 100644 --- a/abogen/web/templates/settings.html +++ b/abogen/web/templates/settings.html @@ -446,9 +446,11 @@
- -

Enter the folder exactly as it appears in Audiobookshelf or paste the folder ID. You can also browse the available folders below.

- +
+ + +
+

Enter the folder exactly as it appears in Audiobookshelf, paste the folder ID, or browse the available folders.

@@ -492,6 +494,30 @@
+ +