feat: Implement folder browsing modal for Audiobookshelf integration and enhance folder selection UI

This commit is contained in:
JB
2025-11-01 06:49:43 -07:00
parent 28652d9e78
commit 2d61674e90
3 changed files with 362 additions and 43 deletions
+227 -40
View File
@@ -17,6 +17,17 @@ const outputAreas = {
const normalizationAudio = document.querySelector('[data-role="normalization-preview-audio"]'); 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) { function setStatus(target, message, state) {
if (!target) { if (!target) {
return; return;
@@ -93,6 +104,197 @@ function parseNumber(value, fallback) {
return Number.isFinite(parsed) ? parsed : 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() { function collectLLMFields() {
const baseUrl = form.querySelector('#llm_base_url'); const baseUrl = form.querySelector('#llm_base_url');
const apiKey = form.querySelector('#llm_api_key'); 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 status = statusSelectors.audiobookshelf;
const fields = collectAudiobookshelfFields(); const fields = collectAudiobookshelfFields();
if (!status) { if (!status) {
@@ -418,7 +620,11 @@ async function listAudiobookshelfFolders(button) {
return; return;
} }
clearStatus(status); clearStatus(status);
setStatus(status, 'Loading folders…'); openFolderModal(button);
if (!folderModal) {
setStatus(status, 'Folder picker is unavailable in this view.', 'error');
return;
}
button.disabled = true; button.disabled = true;
try { try {
const response = await fetch('/api/integrations/audiobookshelf/folders', { const response = await fetch('/api/integrations/audiobookshelf/folders', {
@@ -431,49 +637,29 @@ async function listAudiobookshelfFolders(button) {
throw new Error(payload.error || 'Folder lookup failed.'); throw new Error(payload.error || 'Folder lookup failed.');
} }
const folders = Array.isArray(payload.folders) ? payload.folders : []; const folders = Array.isArray(payload.folders) ? payload.folders : [];
const modalActive = folderModal && !folderModal.hidden;
if (!folders.length) { 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; return;
} }
const list = folders if (!modalActive) {
.map((entry, index) => { setStatus(status, 'Folders loaded.', 'info');
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');
return; return;
} }
const selection = Number.parseInt(choice, 10); populateFolderPicker(folders);
if (!Number.isFinite(selection) || selection < 1 || selection > folders.length) { setStatus(status, 'Choose a folder below.', 'info');
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');
} catch (error) { } 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 { } finally {
button.disabled = false; button.disabled = false;
} }
@@ -590,7 +776,7 @@ function initActions() {
} }
const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]'); const audiobookshelfBrowseButton = document.querySelector('[data-action="audiobookshelf-list-folders"]');
if (audiobookshelfBrowseButton) { if (audiobookshelfBrowseButton) {
audiobookshelfBrowseButton.addEventListener('click', () => listAudiobookshelfFolders(audiobookshelfBrowseButton)); audiobookshelfBrowseButton.addEventListener('click', () => browseAudiobookshelfFolders(audiobookshelfBrowseButton));
} }
} }
@@ -610,5 +796,6 @@ if (form) {
initNavigation(); initNavigation();
initSampleSelector(); initSampleSelector();
initActions(); initActions();
initFolderPicker();
initLLMStateWatchers(); initLLMStateWatchers();
} }
+106
View File
@@ -335,6 +335,100 @@ body {
border-radius: 28px; 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 { .reader-modal {
width: min(960px, 95vw); width: min(960px, 95vw);
height: min(90vh, 720px); height: min(90vh, 720px);
@@ -1995,6 +2089,18 @@ button.step-indicator__item:focus-visible {
gap: 0.35rem; 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 { .preview-card {
border: 1px solid rgba(148, 163, 184, 0.18); border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 16px; border-radius: 16px;
+29 -3
View File
@@ -446,9 +446,11 @@
</div> </div>
<div class="field__group"> <div class="field__group">
<label for="audiobookshelf_folder_id">Folder (name or ID)</label> <label for="audiobookshelf_folder_id">Folder (name or ID)</label>
<input type="text" id="audiobookshelf_folder_id" name="audiobookshelf_folder_id" value="{{ integrations.audiobookshelf.folder_id }}"> <div class="field__input-with-button">
<p class="hint">Enter the folder exactly as it appears in Audiobookshelf or paste the folder ID. You can also browse the available folders below.</p> <input type="text" id="audiobookshelf_folder_id" name="audiobookshelf_folder_id" value="{{ integrations.audiobookshelf.folder_id }}">
<button type="button" class="button button--ghost button--small" data-action="audiobookshelf-list-folders">Browse folders</button> <button type="button" class="button button--ghost button--small" data-action="audiobookshelf-list-folders">Browse folders</button>
</div>
<p class="hint">Enter the folder exactly as it appears in Audiobookshelf, paste the folder ID, or browse the available folders.</p>
</div> </div>
</div> </div>
<div class="field field--inline"> <div class="field field--inline">
@@ -492,6 +494,30 @@
</section> </section>
</div> </div>
<div class="modal" data-role="audiobookshelf-folder-modal" hidden>
<div class="modal__overlay" data-role="audiobookshelf-folder-overlay" tabindex="-1"></div>
<div class="modal__content card card--modal folder-picker-modal" role="dialog" aria-modal="true" aria-labelledby="audiobookshelf-folder-picker-title">
<header class="modal__header">
<p class="modal__eyebrow">Audiobookshelf</p>
<h2 class="modal__title" id="audiobookshelf-folder-picker-title">Select folder</h2>
<button type="button" class="button button--ghost button--small" data-action="audiobookshelf-folder-close" aria-label="Close folder picker">Close</button>
</header>
<div class="modal__body">
<div class="folder-picker" data-role="audiobookshelf-folder-picker">
<div class="folder-picker__controls">
<input type="search" data-role="audiobookshelf-folder-filter" placeholder="Filter folders" autocomplete="off" spellcheck="false">
</div>
<p class="folder-picker__status" data-role="audiobookshelf-folder-status"></p>
<div class="folder-picker__list" data-role="audiobookshelf-folder-list" role="listbox"></div>
<p class="folder-picker__empty" data-role="audiobookshelf-folder-empty" hidden>No folders match your filter.</p>
</div>
</div>
<footer class="modal__footer">
<button type="button" class="button button--ghost" data-action="audiobookshelf-folder-close">Cancel</button>
</footer>
</div>
</div>
<div class="settings__actions"> <div class="settings__actions">
<button type="submit" class="button">Save Settings</button> <button type="submit" class="button">Save Settings</button>
</div> </div>