/** * app-bookmark-save.js * Bookmark dialog (create/update) + group dialog (create/update). */ import { showAsyncFlash } from '../../../../js/components/app-async-flash.js'; import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js'; import { getAppBase } from '../../../../js/pages/app-list-utils.js'; export function initBookmarkSave(root = document, options = {}) { const host = resolveHost(root); const openButtonSelector = String(options.openButtonSelector || '[data-bookmark-open-dialog]').trim() || '[data-bookmark-open-dialog]'; const bookmarkDialogSelector = String(options.dialogSelector || '[data-app-bookmark-dialog]').trim() || '[data-app-bookmark-dialog]'; const groupDialogSelector = String(options.groupDialogSelector || '[data-app-bookmark-group-dialog]').trim() || '[data-app-bookmark-group-dialog]'; const openBtn = host.querySelector(openButtonSelector); const bookmarkDialog = host.matches?.(bookmarkDialogSelector) ? host : host.querySelector(bookmarkDialogSelector); if (!(bookmarkDialog instanceof HTMLDialogElement)) { return { destroy: () => {} }; } if (bookmarkDialog.dataset.bookmarkSaveBound === '1' && bookmarkDialog._bookmarkSaveApi) { return bookmarkDialog._bookmarkSaveApi; } const bookmarkForm = bookmarkDialog.querySelector('[data-bookmark-save-form]'); if (!(bookmarkForm instanceof HTMLFormElement)) { return { destroy: () => {} }; } const groupDialog = host.querySelector(groupDialogSelector); const groupForm = groupDialog instanceof HTMLDialogElement ? groupDialog.querySelector('[data-bookmark-group-save-form]') : null; const hasGroupDialog = groupDialog instanceof HTMLDialogElement && groupForm instanceof HTMLFormElement; const abortController = new AbortController(); const { signal } = abortController; const timerIds = []; const documentRoot = document.documentElement; const csrfKey = String(documentRoot.dataset.csrfKey || '').trim(); const csrfToken = String(documentRoot.dataset.csrfToken || '').trim(); const moduleContext = { module: 'bookmark-save' }; if (!csrfKey || !csrfToken) { warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark dialog', moduleContext); return { destroy: () => {} }; } const nameInput = bookmarkForm.querySelector('input[name="name"]'); const groupSelect = bookmarkForm.querySelector('[data-bookmark-group-select]'); const newGroupBtn = bookmarkForm.querySelector('[data-bookmark-new-group]'); const closeBookmarkBtns = bookmarkDialog.querySelectorAll('[data-bookmark-dialog-close]'); const submitBtn = bookmarkDialog.querySelector('[data-bookmark-submit-label]'); const titleEl = bookmarkDialog.querySelector('#app-bookmark-dialog-title'); const maxBookmarkMessage = bookmarkDialog.dataset.maxMessage || ''; const bookmarkMessageSaved = bookmarkDialog.dataset.msgSaved || 'Bookmark saved'; const bookmarkMessageUpdated = bookmarkDialog.dataset.msgUpdated || 'Bookmark updated'; const bookmarkMessageErrorGeneric = bookmarkDialog.dataset.msgErrorGeneric || 'Bookmark action failed'; const bookmarkMessageNameRequired = bookmarkDialog.dataset.msgNameRequired || 'Bookmark name is required'; const bookmarkApiErrorMessages = { name_required: bookmarkDialog.dataset.msgNameRequired || bookmarkMessageErrorGeneric, url_required: bookmarkDialog.dataset.msgUrlRequired || bookmarkMessageErrorGeneric, group_not_found: bookmarkDialog.dataset.msgGroupNotFound || bookmarkMessageErrorGeneric, not_found: bookmarkMessageErrorGeneric, create_failed: bookmarkMessageErrorGeneric, update_failed: bookmarkMessageErrorGeneric }; let editingBookmarkId = null; let bookmarkReturnState = null; let groupEditingId = null; let groupMode = 'create'; let groupSource = 'sidebar'; const syncModalBodyClass = () => { const isOpen = bookmarkDialog.open || (hasGroupDialog && groupDialog.open); document.body.classList.toggle('modal-is-open', isOpen); }; const reportError = (code, message, details = {}) => { warnOnce(code, message, { ...moduleContext, ...details }); showAsyncFlash('error', message); }; const parseJsonSafe = async (response) => { try { return await response.json(); } catch { return null; } }; const resolveBookmarkApiErrorMessage = (errorCode) => { if (!errorCode) return bookmarkMessageErrorGeneric; return bookmarkApiErrorMessages[errorCode] || bookmarkMessageErrorGeneric; }; const runReloadWithSuccess = (message) => { showAsyncFlash('success', message, 2500); const timerId = window.setTimeout(() => window.location.reload(), 450); timerIds.push(timerId); }; const closeBookmarkDialog = () => { try { if (bookmarkDialog.open) bookmarkDialog.close(); } catch { // no-op } bookmarkForm.removeAttribute('aria-busy'); syncModalBodyClass(); }; const openBookmarkDialog = () => { try { bookmarkDialog.showModal(); syncModalBodyClass(); return true; } catch (error) { reportError('UI_DIALOG_OPEN_FAILED', bookmarkMessageErrorGeneric, { error }); return false; } }; const setBookmarkSubmitPending = (pending) => { if (submitBtn instanceof HTMLButtonElement) { submitBtn.disabled = pending; } bookmarkForm.setAttribute('aria-busy', pending ? 'true' : 'false'); }; const setBookmarkSubmitLabel = (label) => { if (!(submitBtn instanceof HTMLElement)) return; const text = String(label || '').trim(); if (!text) return; submitBtn.textContent = text; }; const applyBookmarkModeUi = (isEditMode) => { if (titleEl) { titleEl.textContent = isEditMode ? (bookmarkDialog.dataset.titleEdit || 'Bookmarks') : (bookmarkDialog.dataset.titleCreate || 'Bookmarks'); } setBookmarkSubmitLabel(isEditMode ? (bookmarkDialog.dataset.labelUpdate || 'Save') : (bookmarkDialog.dataset.labelSave || 'Save')); }; const configureBookmarkModeFromTopbar = () => { if (!(openBtn instanceof HTMLButtonElement)) { applyBookmarkModeUi(false); editingBookmarkId = null; bookmarkForm.reset(); if (nameInput) { const title = document.title.replace(/\s*[|–—].*$/, '').trim(); nameInput.value = title || ''; } return; } const isEditMode = openBtn.hasAttribute('data-bookmark-existing-id'); applyBookmarkModeUi(isEditMode); if (isEditMode) { editingBookmarkId = openBtn.dataset.bookmarkExistingId || null; if (nameInput) nameInput.value = openBtn.dataset.bookmarkExistingName || ''; if (groupSelect) groupSelect.value = openBtn.dataset.bookmarkExistingGroup || ''; return; } editingBookmarkId = null; bookmarkForm.reset(); if (nameInput) { const title = document.title.replace(/\s*[|–—].*$/, '').trim(); nameInput.value = title || ''; } }; const openBookmarkEditFromSidebar = (detail) => { const bookmarkId = Number.parseInt(String(detail?.id ?? ''), 10); const bookmarkName = String(detail?.name ?? '').trim(); const groupId = String(detail?.groupId ?? '').trim(); if (bookmarkId <= 0 || !bookmarkName) { reportError('UI_INVALID_STATE', bookmarkMessageErrorGeneric, { detail }); return; } editingBookmarkId = String(bookmarkId); applyBookmarkModeUi(true); if (nameInput instanceof HTMLInputElement) { nameInput.value = bookmarkName; } if (groupSelect instanceof HTMLSelectElement) { groupSelect.value = groupId; } if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) { nameInput.focus(); nameInput.select(); } }; const captureBookmarkState = () => ({ isEditMode: editingBookmarkId !== null && editingBookmarkId !== '', bookmarkId: editingBookmarkId, name: nameInput instanceof HTMLInputElement ? nameInput.value : '', groupId: groupSelect instanceof HTMLSelectElement ? groupSelect.value : '' }); const restoreBookmarkFromState = (selectedGroupId = null) => { const state = bookmarkReturnState; bookmarkReturnState = null; if (!state) { return; } applyBookmarkModeUi(Boolean(state.isEditMode)); editingBookmarkId = state.isEditMode ? state.bookmarkId : null; if (nameInput instanceof HTMLInputElement) { nameInput.value = state.name || ''; } if (groupSelect instanceof HTMLSelectElement) { groupSelect.value = selectedGroupId ?? state.groupId ?? ''; } if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) { nameInput.focus(); nameInput.select(); } }; // Bookmark dialog open if (openBtn instanceof HTMLButtonElement) { openBtn.addEventListener('click', () => { configureBookmarkModeFromTopbar(); if (openBookmarkDialog() && nameInput instanceof HTMLInputElement) { nameInput.select(); } }, { signal }); } document.addEventListener('app:bookmark-edit', (event) => { const detail = event instanceof CustomEvent ? event.detail : null; openBookmarkEditFromSidebar(detail); }, { signal }); closeBookmarkBtns.forEach((btn) => { btn.addEventListener('click', (event) => { event.preventDefault(); closeBookmarkDialog(); }, { signal }); }); bookmarkDialog.addEventListener('click', (event) => { if (event.target === bookmarkDialog) { closeBookmarkDialog(); } }, { signal }); bookmarkDialog.addEventListener('close', () => { bookmarkForm.removeAttribute('aria-busy'); syncModalBodyClass(); }, { signal }); bookmarkDialog.addEventListener('cancel', () => { bookmarkForm.removeAttribute('aria-busy'); syncModalBodyClass(); }, { signal }); // Save bookmark bookmarkForm.addEventListener('submit', async (event) => { event.preventDefault(); const name = nameInput instanceof HTMLInputElement ? nameInput.value.trim() : ''; if (!name) { showAsyncFlash('warning', bookmarkMessageNameRequired); return; } const body = new FormData(); body.set(csrfKey, csrfToken); body.set('name', name); body.set('group_id', groupSelect instanceof HTMLSelectElement ? groupSelect.value : ''); setBookmarkSubmitPending(true); if (editingBookmarkId) { body.set('id', editingBookmarkId); try { const response = await fetch(getAppBase() + 'bookmarks/update-data', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body }); const json = await parseJsonSafe(response); if (!response.ok || !json) { reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status }); return; } if (!json.ok) { reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error }); return; } closeBookmarkDialog(); runReloadWithSuccess(bookmarkMessageUpdated); } catch (error) { reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error }); } finally { setBookmarkSubmitPending(false); } return; } body.set('url', currentRelativeUrl()); try { const response = await fetch(getAppBase() + 'bookmarks/save-data', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body }); const json = await parseJsonSafe(response); if (!response.ok || !json) { reportError('FETCH_FAILED', bookmarkMessageErrorGeneric, { status: response.status }); return; } if (!json.ok) { if (json.error === 'max_reached') { showAsyncFlash('warning', maxBookmarkMessage || 'Maximum bookmarks reached'); return; } reportError('FETCH_FAILED', resolveBookmarkApiErrorMessage(json.error), { error: json.error }); return; } closeBookmarkDialog(); runReloadWithSuccess(json.mode === 'updated' ? bookmarkMessageUpdated : bookmarkMessageSaved); } catch (error) { reportError('FETCH_ERROR', bookmarkMessageErrorGeneric, { error }); } finally { setBookmarkSubmitPending(false); } }, { signal }); const destroy = () => { abortController.abort(); timerIds.forEach((timerId) => window.clearTimeout(timerId)); timerIds.length = 0; bookmarkForm.removeAttribute('aria-busy'); delete bookmarkDialog.dataset.bookmarkSaveBound; delete bookmarkDialog._bookmarkSaveApi; }; const api = { destroy }; bookmarkDialog.dataset.bookmarkSaveBound = '1'; bookmarkDialog._bookmarkSaveApi = api; if (!hasGroupDialog) { return api; } const groupNameInput = groupForm.querySelector('input[name="name"]'); const groupIconInput = groupForm.querySelector('input[name="icon"]'); const groupIconPicker = groupForm.querySelector('[data-bookmark-group-icon-picker]'); const groupCloseBtns = groupDialog.querySelectorAll('[data-bookmark-group-dialog-close]'); const groupTitleEl = groupDialog.querySelector('#app-bookmark-group-dialog-title'); const groupSubmitBtn = groupDialog.querySelector('[data-bookmark-group-submit-label]'); const groupMessageCreated = groupDialog.dataset.msgCreated || 'Group created'; const groupMessageUpdated = groupDialog.dataset.msgUpdated || 'Group updated'; const groupMessageErrorGeneric = groupDialog.dataset.msgErrorGeneric || 'Group action failed'; const groupMessageNameRequired = groupDialog.dataset.msgNameRequired || 'Group name is required'; const maxGroupMessage = groupDialog.dataset.maxMessage || 'Maximum groups reached'; const groupApiErrorMessages = { name_required: groupMessageNameRequired, not_found: groupMessageErrorGeneric, create_failed: groupMessageErrorGeneric, update_failed: groupMessageErrorGeneric }; const resolveGroupApiErrorMessage = (errorCode) => { if (!errorCode) return groupMessageErrorGeneric; return groupApiErrorMessages[errorCode] || groupMessageErrorGeneric; }; const closeGroupDialog = () => { try { if (groupDialog.open) groupDialog.close(); } catch { // no-op } groupForm.removeAttribute('aria-busy'); syncModalBodyClass(); }; const openGroupDialog = () => { try { groupDialog.showModal(); syncModalBodyClass(); return true; } catch (error) { reportError('UI_DIALOG_OPEN_FAILED', groupMessageErrorGeneric, { error }); return false; } }; const setGroupSubmitPending = (pending) => { if (groupSubmitBtn instanceof HTMLButtonElement) { groupSubmitBtn.disabled = pending; } groupForm.setAttribute('aria-busy', pending ? 'true' : 'false'); }; const setGroupSubmitLabel = (label) => { if (!(groupSubmitBtn instanceof HTMLElement)) return; const text = String(label || '').trim(); if (!text) return; groupSubmitBtn.textContent = text; }; const setGroupIcon = (icon) => { const value = String(icon || '').trim() || 'bi-folder'; if (groupIconInput instanceof HTMLInputElement) { groupIconInput.value = value; } if (groupIconPicker instanceof HTMLElement) { groupIconPicker.querySelectorAll('.app-bookmark-icon-active').forEach((item) => item.classList.remove('app-bookmark-icon-active')); const selected = groupIconPicker.querySelector(`[data-icon="${value}"]`); if (selected instanceof HTMLElement) { selected.classList.add('app-bookmark-icon-active'); } } }; const upsertGroupOption = (group) => { if (!(groupSelect instanceof HTMLSelectElement)) { return; } const id = String(group?.id ?? '').trim(); const name = String(group?.name ?? '').trim(); if (!id || !name) { return; } const existing = Array.from(groupSelect.options).find((option) => option.value === id); if (existing) { existing.textContent = name; } else { const option = document.createElement('option'); option.value = id; option.textContent = name; groupSelect.appendChild(option); } }; const configureGroupDialog = ({ mode, source, id = null, name = '', icon = 'bi-folder', restoreState = null }) => { groupMode = mode === 'edit' ? 'edit' : 'create'; groupSource = source === 'bookmark' ? 'bookmark' : 'sidebar'; groupEditingId = groupMode === 'edit' ? id : null; bookmarkReturnState = groupSource === 'bookmark' ? restoreState : null; groupForm.reset(); setGroupIcon(icon); if (groupNameInput instanceof HTMLInputElement) { groupNameInput.value = String(name || ''); } if (groupTitleEl instanceof HTMLElement) { groupTitleEl.textContent = groupMode === 'edit' ? (groupDialog.dataset.titleEdit || 'Edit group') : (groupDialog.dataset.titleCreate || 'New group'); } setGroupSubmitLabel(groupMode === 'edit' ? (groupDialog.dataset.labelUpdate || 'Save') : (groupDialog.dataset.labelSave || 'Save')); }; const handleGroupCancel = () => { closeGroupDialog(); if (groupSource === 'bookmark' && bookmarkReturnState) { restoreBookmarkFromState(); } else { bookmarkReturnState = null; } }; const openCreateGroupFromBookmarkDialog = () => { const state = captureBookmarkState(); closeBookmarkDialog(); configureGroupDialog({ mode: 'create', source: 'bookmark', restoreState: state }); if (openGroupDialog() && groupNameInput instanceof HTMLInputElement) { groupNameInput.focus(); groupNameInput.select(); } }; if (newGroupBtn instanceof HTMLButtonElement) { newGroupBtn.addEventListener('click', (event) => { event.preventDefault(); openCreateGroupFromBookmarkDialog(); }, { signal }); } if (groupIconPicker instanceof HTMLElement) { groupIconPicker.addEventListener('click', (event) => { const target = event.target instanceof HTMLElement ? event.target.closest('[data-icon]') : null; if (!(target instanceof HTMLElement)) return; event.preventDefault(); setGroupIcon(target.dataset.icon || 'bi-folder'); }, { signal }); } groupCloseBtns.forEach((btn) => { btn.addEventListener('click', (event) => { event.preventDefault(); handleGroupCancel(); }, { signal }); }); groupDialog.addEventListener('click', (event) => { if (event.target === groupDialog) { handleGroupCancel(); } }, { signal }); groupDialog.addEventListener('cancel', (event) => { event.preventDefault(); handleGroupCancel(); }, { signal }); groupDialog.addEventListener('close', () => { groupForm.removeAttribute('aria-busy'); syncModalBodyClass(); }, { signal }); document.addEventListener('app:bookmark-group-edit', (event) => { const detail = event instanceof CustomEvent ? event.detail : null; const groupId = Number.parseInt(String(detail?.id ?? ''), 10); const groupName = String(detail?.name ?? '').trim(); const groupIcon = String(detail?.icon ?? 'bi-folder').trim() || 'bi-folder'; if (groupId <= 0 || !groupName) { reportError('UI_INVALID_STATE', groupMessageErrorGeneric, { detail }); return; } configureGroupDialog({ mode: 'edit', source: 'sidebar', id: groupId, name: groupName, icon: groupIcon }); if (openGroupDialog() && groupNameInput instanceof HTMLInputElement) { groupNameInput.focus(); groupNameInput.select(); } }, { signal }); groupForm.addEventListener('submit', async (event) => { event.preventDefault(); const name = groupNameInput instanceof HTMLInputElement ? groupNameInput.value.trim() : ''; if (!name) { showAsyncFlash('warning', groupMessageNameRequired); return; } const icon = groupIconInput instanceof HTMLInputElement ? groupIconInput.value : 'bi-folder'; const body = new FormData(); body.set(csrfKey, csrfToken); body.set('name', name); body.set('icon', icon); if (groupMode === 'edit' && groupEditingId) { body.set('id', String(groupEditingId)); } setGroupSubmitPending(true); try { const response = await fetch(getAppBase() + 'bookmarks/group-save-data', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body }); const json = await parseJsonSafe(response); if (!response.ok || !json) { reportError('FETCH_FAILED', groupMessageErrorGeneric, { status: response.status }); return; } if (!json.ok) { if (json.error === 'max_reached') { showAsyncFlash('warning', maxGroupMessage); return; } reportError('FETCH_FAILED', resolveGroupApiErrorMessage(json.error), { error: json.error }); return; } if (!json.group) { reportError('UI_INVALID_STATE', groupMessageErrorGeneric, { reason: 'missing_group' }); return; } const mode = json.mode === 'updated' ? 'updated' : 'created'; const successMessage = mode === 'updated' ? groupMessageUpdated : groupMessageCreated; closeGroupDialog(); if (groupSource === 'bookmark') { upsertGroupOption(json.group); restoreBookmarkFromState(String(json.group.id)); showAsyncFlash('success', successMessage, 2000); } else { runReloadWithSuccess(successMessage); } } catch (error) { reportError('FETCH_ERROR', groupMessageErrorGeneric, { error }); } finally { setGroupSubmitPending(false); } }, { signal }); return api; } function currentRelativeUrl() { const basePath = new URL(getAppBase()).pathname; const full = window.location.pathname + window.location.search; if (full.startsWith(basePath)) { return full.slice(basePath.length); } return full.replace(/^\//, ''); }