Files
breadcrumb-the-shire/modules/bookmarks/web/js/components/app-bookmark-panel.js
fs 4871c6032e feat: extract bookmarks as standalone module with MintyPHP\Module\Bookmarks namespace
Moves bookmarks from core hardcoding into modules/bookmarks/ as a
fully self-contained module, following the same pattern as addressbook.

Module contributions via platform slots:
- aside.tab_panel: bookmark sidebar panel
- topbar.right_item: bookmark toggle button
- layout.body_end_template: bookmark/group dialogs
- layout.head_style: bookmark CSS (form + sidebar)
- runtime.component: bookmark-save and bookmark-panel (phase: late)
- search.resource_item: bookmarks in global search (user-scoped via {{userId}})

Backend fully in module namespace (MintyPHP\Module\Bookmarks\*):
- Service: BookmarkService, BookmarkServicesFactory, BookmarkRepositoryFactory
- Repository: BookmarkRepository, BookmarkGroupRepository, BookmarkNavigationRepository
- Support: BookmarkUrlNormalizer
- Providers: BookmarksSessionProvider, BookmarksLayoutProvider, BookmarksSearchProvider

Core cleanup:
- Removed all bookmark-specific markup from core templates
- Removed core DI registrations for bookmark services
- Removed core bookmark pages, JS, CSS
- AuthSessionTenantContextService delegates to module SessionProvider

Architecture guards:
- NoBookmarksHardcodingTest: verifies zero bookmark references in core
- testModuleClassesUseModuleNamespace: prevents core namespace leakage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 22:20:20 +01:00

530 lines
17 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';
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;
}