Files
breadcrumb-the-shire/modules/bookmarks/web/js/components/app-bookmark-save.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

633 lines
21 KiB
JavaScript

/**
* 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(/^\//, '');
}