/** * Sidebar bookmarks panel actions: * - Reorder root navigation (groups + ungrouped bookmarks) via up/down * - Reorder bookmarks inside groups (up/down) * - Edit/Delete groups and bookmarks with confirm dialog */ import { showAsyncFlash } from '../../../../js/components/app-async-flash.js'; import { warnOnce, resolveHost } from '../../../../js/core/app-dom.js'; import { confirmDialog } from '../../../../js/core/app-confirm-dialog.js'; export function initBookmarkPanel(root = document, options = {}) { const host = resolveHost(root); const panelSelector = String(options.selector || '[data-aside-panel="bookmarks"]').trim() || '[data-aside-panel="bookmarks"]'; const panel = host.matches?.(panelSelector) ? host : host.querySelector(panelSelector); if (!(panel instanceof HTMLElement)) { return { destroy: () => {} }; } if (panel.dataset.bookmarkPanelBound === '1' && panel._bookmarkPanelApi) { return panel._bookmarkPanelApi; } const reorderUrl = String(panel.dataset.bookmarkReorderUrl || '').trim(); const groupDeleteUrl = String(panel.dataset.bookmarkGroupDeleteUrl || '').trim(); const bookmarkDeleteUrl = String(panel.dataset.bookmarkDeleteUrl || '').trim(); if (!reorderUrl || !groupDeleteUrl || !bookmarkDeleteUrl) { warnOnce('UI_DATA_MISSING', 'Missing bookmark panel endpoint metadata', { module: 'bookmark-panel' }); return { destroy: () => {} }; } const documentRoot = document.documentElement; const csrfKey = String(documentRoot.dataset.csrfKey || '').trim(); const csrfToken = String(documentRoot.dataset.csrfToken || '').trim(); if (!csrfKey || !csrfToken) { warnOnce('UI_DATA_MISSING', 'Missing CSRF metadata for bookmark panel', { module: 'bookmark-panel' }); return { destroy: () => {} }; } const abortController = new AbortController(); const { signal } = abortController; const timerIds = []; const messageDeleteConfirm = panel.dataset.bookmarkMsgGroupDeleteConfirm || 'Delete group and keep bookmarks?'; const messageGroupDeleted = panel.dataset.bookmarkMsgGroupDeleted || 'Group deleted'; const messageGroupDeleteFailed = panel.dataset.bookmarkMsgGroupDeleteFailed || 'Group delete failed'; const messageBookmarkDeleteConfirm = panel.dataset.bookmarkMsgBookmarkDeleteConfirm || 'Delete this bookmark?'; const messageBookmarkDeleted = panel.dataset.bookmarkMsgBookmarkDeleted || 'Bookmark deleted'; const messageBookmarkDeleteFailed = panel.dataset.bookmarkMsgBookmarkDeleteFailed || 'Bookmark action failed'; const messageGroupActionFailed = panel.dataset.bookmarkMsgGroupActionFailed || messageGroupDeleteFailed; const messageBookmarkActionFailed = panel.dataset.bookmarkMsgBookmarkActionFailed || messageBookmarkDeleteFailed; const messageReorderFailed = panel.dataset.bookmarkMsgReorderFailed || 'Reorder failed'; const labelDelete = panel.dataset.bookmarkLabelDelete || 'Delete'; const moduleContext = { module: 'bookmark-panel' }; panel.querySelectorAll('[data-bookmark-item-action], [data-bookmark-group-action]').forEach((button) => { if (!(button instanceof HTMLButtonElement)) { return; } button.dataset.baseDisabled = button.disabled ? '1' : '0'; }); let pending = false; const actionMenus = () => Array.from(panel.querySelectorAll('[data-bookmark-action-menu]')) .filter((menu) => menu instanceof HTMLDetailsElement); const closeActionMenus = (except = null) => { actionMenus().forEach((menu) => { if (menu === except) { return; } menu.open = false; }); }; const panelActionButtons = () => Array.from( panel.querySelectorAll('[data-bookmark-item-action], [data-bookmark-group-action]') ).filter((button) => button instanceof HTMLButtonElement); const setPending = (nextPending) => { pending = nextPending; panelActionButtons().forEach((button) => { const baselineDisabled = button.dataset.baseDisabled === '1'; button.disabled = nextPending || baselineDisabled; }); }; const parseJsonSafe = async (response) => { try { return await response.json(); } catch { return null; } }; const reportError = (code, message, details = {}) => { warnOnce(code, message, { ...moduleContext, ...details }); showAsyncFlash('error', message); }; const runReload = (successMessage = '') => { if (String(successMessage).trim() !== '') { showAsyncFlash('success', successMessage, 2500); } const timerId = window.setTimeout(() => window.location.reload(), 450); timerIds.push(timerId); }; const directChildren = (container, className) => { if (!(container instanceof HTMLElement)) { return []; } return Array.from(container.children).filter( (child) => child instanceof HTMLElement && child.classList.contains(className) ); }; const swapInList = (items, currentItem, direction) => { const currentIndex = items.indexOf(currentItem); if (currentIndex < 0) { return []; } const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; if (nextIndex < 0 || nextIndex >= items.length) { return []; } const reordered = items.slice(); const temp = reordered[currentIndex]; reordered[currentIndex] = reordered[nextIndex]; reordered[nextIndex] = temp; return reordered; }; const buildPairs = (items, dataKey) => { const pairs = []; for (let index = 0; index < items.length; index += 1) { const item = items[index]; const id = Number.parseInt(item.dataset[dataKey] || '', 10); if (id <= 0) { return []; } pairs.push({ id, sort_order: index + 1 }); } return pairs; }; const buildRootPairs = (items) => { const pairs = []; for (let index = 0; index < items.length; index += 1) { const item = items[index]; const kind = String(item.dataset.bookmarkRootKind || '').trim(); const id = Number.parseInt(item.dataset.bookmarkRootId || '', 10); if ((kind !== 'group' && kind !== 'bookmark') || id <= 0) { return []; } pairs.push({ kind, id, sort_order: index + 1 }); } return pairs; }; const submitReorder = async (type, pairs) => { if (pairs.length === 0) { return; } const body = new FormData(); body.set(csrfKey, csrfToken); body.set('type', type); body.set('items', JSON.stringify(pairs)); setPending(true); try { const response = await fetch(reorderUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body }); const json = await parseJsonSafe(response); if (!response.ok || !json) { reportError('FETCH_FAILED', messageReorderFailed, { status: response.status, type }); return; } if (!json.ok) { reportError('FETCH_FAILED', messageReorderFailed, { error: json.error, type }); return; } runReload(); } catch (error) { reportError('FETCH_ERROR', messageReorderFailed, { error, type }); } finally { setPending(false); } }; const reorderGroup = async (button) => { const groupItem = button.closest('.app-sidebar-bookmark-root-item.app-sidebar-bookmark-group'); if (!(groupItem instanceof HTMLElement)) { return; } const container = groupItem.parentElement; const rootItems = directChildren(container, 'app-sidebar-bookmark-root-item'); if (rootItems.length < 2) { return; } const action = String(button.dataset.bookmarkGroupAction || '').trim(); const direction = action === 'move-up' ? 'up' : action === 'move-down' ? 'down' : ''; if (!direction) { return; } const reorderedRootItems = swapInList(rootItems, groupItem, direction); if (reorderedRootItems.length === 0) { return; } const pairs = buildRootPairs(reorderedRootItems); if (pairs.length !== reorderedRootItems.length) { reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'root' }); return; } await submitReorder('root', pairs); }; const reorderBookmark = async (button) => { const bookmarkItem = button.closest('.app-sidebar-bookmark-item'); if (!(bookmarkItem instanceof HTMLElement)) { return; } const action = String(button.dataset.bookmarkItemAction || '').trim(); const direction = action === 'move-up' ? 'up' : action === 'move-down' ? 'down' : ''; if (!direction) { return; } const scope = String(button.dataset.bookmarkItemActionScope || '').trim(); if (scope === 'root') { const rootItem = bookmarkItem.closest('.app-sidebar-bookmark-root-item'); if (!(rootItem instanceof HTMLElement)) { return; } const container = rootItem.parentElement; const rootItems = directChildren(container, 'app-sidebar-bookmark-root-item'); if (rootItems.length < 2) { return; } const reorderedRootItems = swapInList(rootItems, rootItem, direction); if (reorderedRootItems.length === 0) { return; } const rootPairs = buildRootPairs(reorderedRootItems); if (rootPairs.length !== reorderedRootItems.length) { reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'root' }); return; } await submitReorder('root', rootPairs); return; } const container = bookmarkItem.parentElement; const bookmarks = directChildren(container, 'app-sidebar-bookmark-item'); if (bookmarks.length < 2) { return; } const reorderedBookmarks = swapInList(bookmarks, bookmarkItem, direction); if (reorderedBookmarks.length === 0) { return; } const pairs = buildPairs(reorderedBookmarks, 'bookmarkId'); if (pairs.length !== reorderedBookmarks.length) { reportError('UI_INVALID_STATE', messageReorderFailed, { action, target: 'bookmarks' }); return; } await submitReorder('bookmarks', pairs); }; const deleteGroup = async (button) => { const groupItem = button.closest('.app-sidebar-bookmark-group'); if (!(groupItem instanceof HTMLElement)) { return; } const groupId = Number.parseInt(groupItem.dataset.bookmarkGroupId || '', 10); if (groupId <= 0) { reportError('UI_INVALID_STATE', messageGroupDeleteFailed, { reason: 'invalid_group_id' }); return; } const approved = await confirmDialog.confirm({ message: messageDeleteConfirm, confirmLabel: labelDelete, actionKind: 'delete', variant: 'danger', focus: 'cancel' }); if (!approved) { return; } const body = new FormData(); body.set(csrfKey, csrfToken); body.set('id', String(groupId)); setPending(true); try { const response = await fetch(groupDeleteUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body }); const json = await parseJsonSafe(response); if (!response.ok || !json) { reportError('FETCH_FAILED', messageGroupDeleteFailed, { status: response.status, groupId }); return; } if (!json.ok) { reportError('FETCH_FAILED', messageGroupDeleteFailed, { error: json.error, groupId }); return; } runReload(messageGroupDeleted); } catch (error) { reportError('FETCH_ERROR', messageGroupDeleteFailed, { error, groupId }); } finally { setPending(false); } }; const deleteBookmark = async (button) => { const bookmarkItem = button.closest('.app-sidebar-bookmark-item'); if (!(bookmarkItem instanceof HTMLElement)) { return; } const bookmarkId = Number.parseInt(bookmarkItem.dataset.bookmarkId || '', 10); if (bookmarkId <= 0) { reportError('UI_INVALID_STATE', messageBookmarkDeleteFailed, { reason: 'invalid_bookmark_id' }); return; } const approved = await confirmDialog.confirm({ message: messageBookmarkDeleteConfirm, confirmLabel: labelDelete, actionKind: 'delete', variant: 'danger', focus: 'cancel' }); if (!approved) { return; } const body = new FormData(); body.set(csrfKey, csrfToken); body.set('id', String(bookmarkId)); setPending(true); try { const response = await fetch(bookmarkDeleteUrl, { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, body }); const json = await parseJsonSafe(response); if (!response.ok || !json) { reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { status: response.status, bookmarkId }); return; } if (!json.ok) { reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { error: json.error, bookmarkId }); return; } runReload(messageBookmarkDeleted); } catch (error) { reportError('FETCH_ERROR', messageBookmarkDeleteFailed, { error, bookmarkId }); } finally { setPending(false); } }; const editGroup = (button) => { const groupItem = button.closest('.app-sidebar-bookmark-group'); if (!(groupItem instanceof HTMLElement)) { return; } const groupId = Number.parseInt(groupItem.dataset.bookmarkGroupId || '', 10); const groupName = String(groupItem.dataset.bookmarkGroupName || '').trim(); const groupIcon = String(groupItem.dataset.bookmarkGroupIcon || 'bi-folder').trim() || 'bi-folder'; if (groupId <= 0 || !groupName) { reportError('UI_INVALID_STATE', messageGroupActionFailed, { reason: 'invalid_group_payload' }); return; } document.dispatchEvent(new CustomEvent('app:bookmark-group-edit', { detail: { id: groupId, name: groupName, icon: groupIcon } })); }; const editBookmark = (button) => { const bookmarkItem = button.closest('.app-sidebar-bookmark-item'); if (!(bookmarkItem instanceof HTMLElement)) { return; } const bookmarkId = Number.parseInt(bookmarkItem.dataset.bookmarkId || '', 10); const bookmarkName = String(bookmarkItem.dataset.bookmarkName || '').trim(); const groupId = Number.parseInt(bookmarkItem.dataset.bookmarkGroupId || '', 10); if (bookmarkId <= 0 || !bookmarkName) { reportError('UI_INVALID_STATE', messageBookmarkActionFailed, { reason: 'invalid_bookmark_payload' }); return; } document.dispatchEvent(new CustomEvent('app:bookmark-edit', { detail: { id: bookmarkId, name: bookmarkName, groupId: groupId > 0 ? String(groupId) : '' } })); }; panel.addEventListener('click', (event) => { const target = event.target instanceof HTMLElement ? event.target : null; if (!target) { return; } const menuSummary = target.closest('[data-bookmark-action-menu] > summary'); if (menuSummary instanceof HTMLElement) { if (pending) { event.preventDefault(); event.stopPropagation(); return; } const menu = menuSummary.parentElement; if (menu instanceof HTMLDetailsElement) { event.preventDefault(); event.stopPropagation(); const shouldOpen = !menu.open; closeActionMenus(menu); menu.open = shouldOpen; } return; } if (pending) { return; } const groupActionButton = target.closest('[data-bookmark-group-action]'); if (groupActionButton instanceof HTMLButtonElement) { event.preventDefault(); event.stopPropagation(); closeActionMenus(); const action = String(groupActionButton.dataset.bookmarkGroupAction || '').trim(); if (action === 'edit') { editGroup(groupActionButton); return; } if (action === 'delete') { void deleteGroup(groupActionButton); return; } if (action === 'move-up' || action === 'move-down') { void reorderGroup(groupActionButton); } return; } const itemActionButton = target.closest('[data-bookmark-item-action]'); if (itemActionButton instanceof HTMLButtonElement) { event.preventDefault(); event.stopPropagation(); closeActionMenus(); const action = String(itemActionButton.dataset.bookmarkItemAction || '').trim(); if (action === 'edit') { editBookmark(itemActionButton); return; } if (action === 'delete') { void deleteBookmark(itemActionButton); return; } if (action === 'move-up' || action === 'move-down') { void reorderBookmark(itemActionButton); } } }, { signal }); document.addEventListener('click', (event) => { if (!(event.target instanceof Node)) { return; } const clickedInsideMenu = actionMenus().some((menu) => menu.contains(event.target)); if (!clickedInsideMenu) { closeActionMenus(); } }, { signal }); const destroy = () => { abortController.abort(); timerIds.forEach((timerId) => window.clearTimeout(timerId)); timerIds.length = 0; delete panel.dataset.bookmarkPanelBound; delete panel._bookmarkPanelApi; }; const api = { destroy }; panel.dataset.bookmarkPanelBound = '1'; panel._bookmarkPanelApi = api; return api; }