forked from fa/breadcrumb-the-shire
496 lines
16 KiB
JavaScript
496 lines
16 KiB
JavaScript
/**
|
|
* 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';
|
|
import { postForm } from '/js/core/app-http.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 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 json = await postForm(reorderUrl, body);
|
|
if (!json.ok) {
|
|
reportError('FETCH_FAILED', messageReorderFailed, { error: json.error, type });
|
|
return;
|
|
}
|
|
|
|
runReload();
|
|
} catch (error) {
|
|
reportError('FETCH_ERROR', messageReorderFailed, { error, status: error?.status, 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 json = await postForm(groupDeleteUrl, body);
|
|
if (!json.ok) {
|
|
reportError('FETCH_FAILED', messageGroupDeleteFailed, { error: json.error, groupId });
|
|
return;
|
|
}
|
|
|
|
runReload(messageGroupDeleted);
|
|
} catch (error) {
|
|
reportError('FETCH_ERROR', messageGroupDeleteFailed, { error, status: error?.status, 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 json = await postForm(bookmarkDeleteUrl, body);
|
|
if (!json.ok) {
|
|
reportError('FETCH_FAILED', messageBookmarkDeleteFailed, { error: json.error, bookmarkId });
|
|
return;
|
|
}
|
|
|
|
runReload(messageBookmarkDeleted);
|
|
} catch (error) {
|
|
reportError('FETCH_ERROR', messageBookmarkDeleteFailed, { error, status: error?.status, 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;
|
|
}
|