big restructure

This commit is contained in:
2026-02-11 19:28:12 +01:00
parent cd59ccd99b
commit 3eb9cc0ac4
209 changed files with 5101 additions and 2459 deletions

View File

@@ -1,3 +1,5 @@
import { warnOnce } from '../core/app-dom.js';
/**
* Async Flash Messages & Loading State
* Renders flash messages into #async-messages for JS-triggered notifications
@@ -46,7 +48,7 @@ const defaultTimeouts = {
export function showAsyncFlash(type, message, timeout) {
const container = document.getElementById('async-messages');
if (!container) {
console.warn('async-messages container not found');
warnOnce('UI_EL_MISSING', 'Missing #async-messages container', { module: 'async-flash' });
return null;
}

View File

@@ -1,3 +1,5 @@
import { warnOnce } from '../core/app-dom.js';
export function bindBulkVisibility({
containerSelector,
buttonSelector,
@@ -5,7 +7,15 @@ export function bindBulkVisibility({
} = {}) {
const container = document.querySelector(containerSelector);
const buttons = Array.from(document.querySelectorAll(buttonSelector));
if (!container || !buttons.length) return { update: () => {} };
if (!container || !buttons.length) {
if (!container) {
warnOnce('UI_EL_MISSING', `Missing bulk container: ${containerSelector}`, { module: 'bulk-selection' });
}
if (!buttons.length) {
warnOnce('UI_EL_MISSING', `Missing bulk buttons: ${buttonSelector}`, { module: 'bulk-selection' });
}
return { update: () => {} };
}
const update = () => {
const selected = container.querySelectorAll(selectedRowSelector).length > 0;

View File

@@ -1,12 +1,16 @@
import { warnOnce } from '../core/app-dom.js';
const toggles = document.querySelectorAll('[data-color-default-toggle]');
const syncToggle = (toggle) => {
const targetSelector = toggle.dataset.colorTarget || '';
if (!targetSelector) {
warnOnce('UI_EL_MISSING', 'Missing data-color-target', { module: 'color-default-toggle' });
return;
}
const target = document.querySelector(targetSelector);
if (!target) {
warnOnce('UI_EL_MISSING', `Missing color target: ${targetSelector}`, { module: 'color-default-toggle' });
return;
}
const defaultValue = toggle.dataset.colorDefault || '#2fa4a4';
@@ -27,6 +31,9 @@ const syncToggle = (toggle) => {
toggles.forEach((toggle) => {
const targetSelector = toggle.dataset.colorTarget || '';
const target = targetSelector ? document.querySelector(targetSelector) : null;
if (targetSelector && !target) {
warnOnce('UI_EL_MISSING', `Missing color target: ${targetSelector}`, { module: 'color-default-toggle' });
}
toggle.addEventListener('change', () => syncToggle(toggle));

View File

@@ -0,0 +1,41 @@
import { requireEl } from '../core/app-dom.js';
const STORAGE_KEY = 'app-contrast';
const setContrast = (contrast) => {
const root = document.documentElement;
root.dataset.contrast = contrast === 'high' ? 'high' : 'normal';
};
const setIcon = (button, contrast) => {
const icon = button.querySelector('i');
if (!icon) return;
icon.classList.remove('bi-circle-half', 'bi-highlights');
icon.classList.add(contrast === 'high' ? 'bi-highlights' : 'bi-circle-half');
};
const initContrastToggle = () => {
const button = requireEl('[data-contrast-toggle]', { module: 'contrast-toggle' });
if (!button) return;
const root = document.documentElement;
const getCurrent = () => (root.dataset.contrast === 'high' ? 'high' : 'normal');
setIcon(button, getCurrent());
button.addEventListener('click', () => {
const current = getCurrent();
const next = current === 'high' ? 'normal' : 'high';
setContrast(next);
setIcon(button, next);
try {
window.localStorage.setItem(STORAGE_KEY, next);
} catch (e) {
// ignore storage errors
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initContrastToggle);
} else {
initContrastToggle();
}

View File

@@ -0,0 +1,73 @@
import { warnOnce } from '../core/app-dom.js';
const getEmptyValues = (field) => {
const own = field?.dataset?.filterEmpty || '';
const parent = field?.closest?.('[data-filter-empty]')?.dataset?.filterEmpty || '';
return [own, parent]
.filter(Boolean)
.join(',')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
};
const isFieldActive = (field) => {
if (!field) return false;
if (field.disabled) return false;
if (field.type === 'hidden') return false;
if (field.type === 'checkbox') return field.checked;
if (field.tagName === 'SELECT' && field.multiple) {
return Array.from(field.selectedOptions || []).some((option) => option.value);
}
if (field.tagName === 'SELECT') {
const value = String(field.value || '').trim();
const empty = new Set(['', 'all', ...getEmptyValues(field)]);
return value !== '' && !empty.has(value);
}
return String(field.value || '').trim() !== '';
};
const isOptionalActive = (item) => {
const fields = item.matches('input, select, textarea')
? [item]
: Array.from(item.querySelectorAll('input, select, textarea'));
return fields.some(isFieldActive);
};
export function initFilterOverflow(root = document) {
const toolbars = root.querySelectorAll('[data-filter-overflow]');
toolbars.forEach((toolbar) => {
if (toolbar.dataset.filterOverflowBound === '1') return;
toolbar.dataset.filterOverflowBound = '1';
const optional = Array.from(toolbar.querySelectorAll('[data-filter-optional]'));
if (!optional.length) return;
const toggle = toolbar.querySelector('[data-filter-toggle]');
if (!toggle) {
warnOnce('UI_EL_MISSING', 'Missing filter overflow toggle', { module: 'filter-overflow' });
return;
}
const setExpanded = (expanded) => {
optional.forEach((item) => {
item.hidden = !expanded;
});
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
};
const hasActiveOptional = optional.some(isOptionalActive);
setExpanded(hasActiveOptional);
toggle.addEventListener('click', () => {
const expanded = toggle.getAttribute('aria-expanded') === 'true';
setExpanded(!expanded);
});
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initFilterOverflow());
} else {
initFilterOverflow();
}

View File

@@ -1,8 +1,13 @@
const refreshFsLightbox = () => {
if (typeof window.requestFsLightboxRefresh === 'function') {
return window.requestFsLightboxRefresh();
}
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
window.__fsLightboxRefreshPending = false;
return true;
}
window.__fsLightboxRefreshPending = true;
return false;
};
@@ -46,3 +51,7 @@ if (document.readyState === 'loading') {
refreshFsLightbox();
initObserver();
}
if (window.__fsLightboxRefreshPending && typeof window.refreshFsLightbox === 'function') {
refreshFsLightbox();
}

View File

@@ -1,18 +1,23 @@
const input = document.querySelector('#side-search');
const resultsEl = document.querySelector('[data-global-search-results]');
import { requireEl, optionalEl, warnOnce } from '../core/app-dom.js';
if (!input || !resultsEl) {
if (!input) console.warn('[GlobalSearch] #side-search input not found');
if (!resultsEl) console.warn('[GlobalSearch] [data-global-search-results] container not found');
} else {
const panel = document.querySelector('#aside-panel-search');
const toggleButton = document.querySelector('[data-search-details-toggle]');
const toggleIcon = document.querySelector('[data-search-details-icon]');
const input = requireEl('#side-search', { module: 'global-search' });
const resultsEl = requireEl('[data-global-search-results]', { module: 'global-search' });
if (input && resultsEl) {
const panel = optionalEl('#aside-panel-search');
const toggleButton = optionalEl('[data-search-details-toggle]');
const toggleIcon = optionalEl('[data-search-details-icon]');
const appBase = window.APP_BASE || document.baseURI;
const endpoint = new URL('admin/search/data', appBase);
const resultsPage = new URL('search', appBase);
const minLength = 2;
let pending = null;
const shortcutLabel = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent)
? '⌘K'
: 'Ctrl+K';
if (input.placeholder && !input.placeholder.includes('⌘') && !input.placeholder.includes('Ctrl+K')) {
input.placeholder = `${input.placeholder} (${shortcutLabel})`;
}
const itemsByKey = new Map();
resultsEl.querySelectorAll('[data-search-key]').forEach((item) => {
@@ -20,7 +25,7 @@ if (!input || !resultsEl) {
if (key) itemsByKey.set(key, item);
});
if (itemsByKey.size === 0) {
console.warn('[GlobalSearch] No [data-search-key] elements found in results container');
warnOnce('UI_EL_MISSING', 'No [data-search-key] elements found in results container', { module: 'global-search' });
}
const setLoadingState = (value) => {
@@ -33,7 +38,7 @@ if (!input || !resultsEl) {
items.forEach((item) => {
const li = itemsByKey.get(item.key);
if (!li) {
console.warn('[GlobalSearch] No element found for key:', item.key);
warnOnce('UI_EL_MISSING', `No element found for key: ${item.key}`, { module: 'global-search' });
return;
}
const countEl = li.querySelector('[data-search-count]');
@@ -54,7 +59,10 @@ if (!input || !resultsEl) {
if (Array.isArray(item.items) && item.items.length > 0) {
item.items.forEach((previewItem, idx) => {
if (!previewItem || !previewItem.url) {
console.warn('[GlobalSearch] Preview item missing url at index', idx, 'for key:', item.key, previewItem);
warnOnce('UI_DATA_MISSING', `Preview item missing url at index ${idx} for key: ${item.key}`, {
module: 'global-search',
item: previewItem
});
return;
}
const previewLi = document.createElement('li');
@@ -95,7 +103,9 @@ if (!input || !resultsEl) {
setLoadingState('…');
const response = await fetch(url.toString(), { headers: { 'Accept': 'application/json' } });
if (!response.ok) {
console.warn('[GlobalSearch] Fetch failed with status:', response.status, response.statusText);
warnOnce('FETCH_FAILED', `Global search failed: ${response.status} ${response.statusText}`, {
module: 'global-search'
});
clear();
return;
}
@@ -103,7 +113,10 @@ if (!input || !resultsEl) {
if (Array.isArray(data)) {
render(data, value);
} else {
console.warn('[GlobalSearch] Invalid response format, expected array but got:', typeof data, data);
warnOnce('FETCH_INVALID', `Invalid response format for global search`, {
module: 'global-search',
type: typeof data
});
clear();
}
};
@@ -117,7 +130,7 @@ if (!input || !resultsEl) {
if (pending) window.clearTimeout(pending);
pending = window.setTimeout(() => {
fetchCounts(value).catch((err) => {
console.warn('[GlobalSearch] Fetch error:', err);
warnOnce('FETCH_ERROR', 'Global search fetch error', { module: 'global-search', err });
clear();
});
}, 300);
@@ -137,6 +150,39 @@ if (!input || !resultsEl) {
submitSearch();
});
const isEditableTarget = (target) => {
if (!target) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
const focusSearch = () => {
if (!input) return;
window.requestAnimationFrame(() => {
input.focus();
input.select();
});
};
const openSearch = () => {
if (window.AppAsidePanels?.open) {
window.AppAsidePanels.open('search');
} else {
document.querySelector('[data-aside-target="search"]')?.click();
}
focusSearch();
};
document.addEventListener('keydown', (event) => {
if (!(event.metaKey || event.ctrlKey)) return;
const key = event.key.toLowerCase();
if (key !== 'k' && key !== '/') return;
if (isEditableTarget(event.target) && event.target !== input) return;
event.preventDefault();
openSearch();
});
const form = input.closest('form');
if (form) {
form.addEventListener('submit', (event) => {
@@ -153,9 +199,6 @@ if (!input || !resultsEl) {
toggleIcon.classList.toggle('bi-plus-square');
}
});
} else if (toggleButton || panel) {
if (!toggleButton) console.warn('[GlobalSearch] [data-search-details-toggle] button not found');
if (!panel) console.warn('[GlobalSearch] #aside-panel-search panel not found');
}
// Seed from current URL search param if present

View File

@@ -0,0 +1,98 @@
import { getMultiSelectInstance } from './app-multiselect-init.js';
import { warnOnce } from '../core/app-dom.js';
const parseValueList = (value) =>
String(value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
const setDisabledState = (option, disabled) => {
option.dataset.disabled = disabled ? '1' : '0';
option.classList.toggle('is-disabled', disabled);
option.setAttribute('aria-disabled', disabled ? 'true' : 'false');
option.style.pointerEvents = disabled ? 'none' : '';
if (!option._cascadeGuard) {
option.addEventListener(
'click',
(event) => {
if (option.dataset.disabled === '1') {
event.preventDefault();
event.stopImmediatePropagation();
}
},
true
);
option._cascadeGuard = true;
}
};
const toggleSelectAll = (instance, disabled) => {
const selectAll = instance?.element?.querySelector('.multi-select-all');
if (!selectAll) return;
selectAll.classList.toggle('is-disabled', disabled);
selectAll.setAttribute('aria-disabled', disabled ? 'true' : 'false');
selectAll.style.pointerEvents = disabled ? 'none' : '';
};
export const initMultiSelectCascade = ({
parent,
child,
map = {},
mode = 'disable',
clearInvalid = true,
}) => {
const parentInput = typeof parent === 'string' ? document.querySelector(parent) : parent;
const childInput = typeof child === 'string' ? document.querySelector(child) : child;
if (!parentInput || !childInput) {
if (!parentInput) {
warnOnce('UI_EL_MISSING', `Missing multiselect parent: ${parent}`, { module: 'multiselect-cascade' });
}
if (!childInput) {
warnOnce('UI_EL_MISSING', `Missing multiselect child: ${child}`, { module: 'multiselect-cascade' });
}
return;
}
const childInstance = getMultiSelectInstance(childInput);
if (!childInstance) {
warnOnce('UI_INIT_MISSING', 'Missing multiselect instance for child', { module: 'multiselect-cascade' });
return;
}
const apply = () => {
const selectedParents = parseValueList(parentInput.value);
const allowAll = selectedParents.length === 0;
const allowed = new Set();
if (!allowAll) {
selectedParents.forEach((id) => {
const entries = map[id] || [];
entries.forEach((entry) => allowed.add(String(entry)));
});
}
if (clearInvalid && !allowAll) {
const selected = childInstance.selectedValues || [];
selected.forEach((value) => {
if (!allowed.has(String(value))) {
childInstance.unselect(value);
}
});
}
const options = childInstance.element.querySelectorAll('.multi-select-option');
let disabledCount = 0;
options.forEach((option) => {
const isAllowed = allowAll || allowed.has(String(option.dataset.value || ''));
const disabled = mode === 'disable' ? !isAllowed : false;
if (disabled) disabledCount += 1;
setDisabledState(option, disabled);
});
toggleSelectAll(childInstance, disabledCount > 0);
};
parentInput.addEventListener('change', apply);
apply();
};

View File

@@ -1,3 +1,5 @@
import { warnOnce } from '../core/app-dom.js';
let multiSelectLoader = null;
const resolveScriptUrl = (script) => {
@@ -65,13 +67,14 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
try {
instance = new MultiSelect(element, options);
} catch (error) {
console.error('[multi-select] init failed', element, error);
warnOnce('UI_INIT_FAIL', 'multi-select init failed', { module: 'multi-select', element, error });
return;
}
if (element.dataset.disabled === 'true' || element.disabled) {
instance.disable();
}
element.dataset.multiSelectInit = '1';
element._multiSelectInstance = instance;
instances.push(instance);
if (syncInput) {
syncInput._multiSelectInstance = instance;
@@ -98,8 +101,22 @@ export const initMultiSelect = async (target = '[data-multi-select]') => {
return instances;
};
export const getMultiSelectInstance = (target) => {
const element = typeof target === 'string' ? document.querySelector(target) : target;
if (!element) return null;
if (element._multiSelectInstance) return element._multiSelectInstance;
const syncTarget = element.dataset?.syncTarget;
if (syncTarget) {
const syncElement = document.querySelector(syncTarget);
if (syncElement && syncElement._multiSelectInstance) return syncElement._multiSelectInstance;
}
return null;
};
const autoInit = () => {
initMultiSelect().catch(() => {});
initMultiSelect().catch((error) => {
warnOnce('UI_INIT_FAIL', 'multi-select auto init failed', { module: 'multi-select', error });
});
};
if (document.readyState === 'loading') {

View File

@@ -19,6 +19,29 @@ const initHistoryButtons = () => {
const back = document.querySelector('#global-back');
const forward = document.querySelector('#global-forward');
const isEditableTarget = (target) => {
if (!target) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
document.addEventListener('keydown', (event) => {
if (!event.altKey || event.metaKey || event.ctrlKey) return;
if (isEditableTarget(event.target)) return;
if (event.key === 'ArrowLeft') {
if (window.history.length > 1) {
event.preventDefault();
window.history.back();
}
} else if (event.key === 'ArrowRight') {
if (window.history.length > 1) {
event.preventDefault();
window.history.forward();
}
}
});
if (back) {
back.addEventListener('click', (event) => {
if (window.history.length > 1) {

View File

@@ -1,12 +1,16 @@
import EditorJS from '../../vendor/editorjs/editorjs.mjs';
import { warnOnce } from '../core/app-dom.js';
const form = document.querySelector('[data-page-editor]');
if (form) {
const holder = form.querySelector('[data-editor-holder]');
if (!holder) {
// No holder available, skip initialization.
warnOnce('UI_EL_MISSING', 'Missing editor holder', { module: 'page-editor' });
} else {
const contentField = form.querySelector('#page-content');
if (!contentField) {
warnOnce('UI_EL_MISSING', 'Missing #page-content input', { module: 'page-editor' });
}
const toggleButton = document.querySelector('[data-editor-toggle]');
const saveButton = document.querySelector('[data-editor-save]');
const canEdit = form.dataset.canEdit === '1';

View File

@@ -1,3 +1,5 @@
import { warnOnce } from '../core/app-dom.js';
const rules = {
min: (value, min) => value.length >= min,
upper: (value) => /[A-Z]/.test(value),
@@ -25,6 +27,15 @@ export function initPasswordHints() {
const passwordInput = passwordSelector ? document.querySelector(passwordSelector) : null;
const confirmInput = confirmSelector ? document.querySelector(confirmSelector) : null;
const emailInput = emailSelector ? document.querySelector(emailSelector) : null;
if (passwordSelector && !passwordInput) {
warnOnce('UI_EL_MISSING', `Missing password input: ${passwordSelector}`, { module: 'password-hints' });
}
if (confirmSelector && !confirmInput) {
warnOnce('UI_EL_MISSING', `Missing confirm input: ${confirmSelector}`, { module: 'password-hints' });
}
if (emailSelector && !emailInput) {
warnOnce('UI_EL_MISSING', `Missing email input: ${emailSelector}`, { module: 'password-hints' });
}
const minLength = Number.parseInt(container.dataset.minLength || '12', 10);
const items = container.querySelectorAll('[data-rule]');

View File

@@ -1,3 +1,5 @@
import { warnOnce } from '../core/app-dom.js';
const switchTenant = async (link) => {
const tenantId = link.dataset.switchTenant;
const csrfKey = link.dataset.csrfKey;
@@ -5,7 +7,7 @@ const switchTenant = async (link) => {
const errorMessage = link.dataset.errorMessage || 'Failed to switch tenant';
if (!tenantId || !csrfKey || !csrfToken) {
console.error('Missing tenant ID or CSRF token');
warnOnce('UI_DATA_MISSING', 'Missing tenant ID or CSRF token', { module: 'tenant-switcher' });
return;
}
@@ -34,19 +36,19 @@ const switchTenant = async (link) => {
if (data.ok) {
window.location.reload();
} else {
console.error('Tenant switch failed:', data.error);
warnOnce('FETCH_FAILED', 'Tenant switch failed', { module: 'tenant-switcher', error: data.error });
alert(errorMessage);
link.style.pointerEvents = '';
link.style.opacity = '';
}
} else {
console.error('Server error:', response.status);
warnOnce('FETCH_FAILED', `Tenant switch server error: ${response.status}`, { module: 'tenant-switcher' });
alert(errorMessage);
link.style.pointerEvents = '';
link.style.opacity = '';
}
} catch (error) {
console.error('Network error:', error);
warnOnce('FETCH_ERROR', 'Tenant switch network error', { module: 'tenant-switcher', error });
alert(errorMessage);
link.style.pointerEvents = '';
link.style.opacity = '';
@@ -55,6 +57,10 @@ const switchTenant = async (link) => {
const initTenantSwitcher = () => {
const tenantLinks = document.querySelectorAll('[data-switch-tenant]');
if (!tenantLinks.length) {
warnOnce('UI_EL_MISSING', 'Missing tenant switch links', { module: 'tenant-switcher' });
return;
}
tenantLinks.forEach(link => {
link.addEventListener('click', (e) => {

View File

@@ -1,19 +1,21 @@
import { optionalEl, warnOnce } from '../core/app-dom.js';
const setTheme = (theme) => {
const root = document.documentElement;
root.dataset.theme = theme;
document.documentElement.dataset.theme = theme;
};
const setIcon = (button, theme) => {
const icon = button.querySelector('i');
if (!icon) return;
icon.classList.remove('bi-sun-fill', 'bi-moon-stars-fill');
icon.classList.add(theme === 'dark' ? 'bi-moon-stars-fill' : 'bi-sun-fill');
const isDarkTheme = (theme) => theme && theme.startsWith('dark');
const setIcon = (iconEl, theme) => {
if (!iconEl) return;
iconEl.classList.remove('bi-sun-fill', 'bi-moon-stars-fill');
iconEl.classList.add(isDarkTheme(theme) ? 'bi-moon-stars-fill' : 'bi-sun-fill');
};
const updateTheme = async (button, theme) => {
const url = button.dataset.themeUrl;
const csrfKey = button.dataset.csrfKey;
const csrfToken = button.dataset.csrfToken;
const updateTheme = async (menu, theme) => {
const url = menu.dataset.themeUrl;
const csrfKey = menu.dataset.csrfKey;
const csrfToken = menu.dataset.csrfToken;
if (!url || !csrfKey || !csrfToken) {
return true;
}
@@ -30,28 +32,78 @@ const updateTheme = async (button, theme) => {
return response.ok;
};
const initThemeMenu = () => {
const menu = optionalEl('[data-theme-menu]');
if (!menu) return;
const icon = menu.querySelector('[data-theme-icon]');
const options = Array.from(menu.querySelectorAll('[data-theme-option]'));
if (!options.length) {
warnOnce('UI_EL_MISSING', 'Missing theme options', { module: 'theme-toggle' });
return;
}
const getCurrent = () => document.documentElement.dataset.theme || '';
const setActive = (theme) => {
options.forEach((option) => {
const isActive = option.dataset.themeValue === theme;
option.classList.toggle('active', isActive);
if (isActive) {
option.setAttribute('aria-current', 'true');
} else {
option.removeAttribute('aria-current');
}
});
};
setIcon(icon, getCurrent());
setActive(getCurrent());
options.forEach((option) => {
option.addEventListener('click', async (event) => {
event.preventDefault();
const next = option.dataset.themeValue || '';
if (!next) return;
const current = getCurrent();
if (next === current) return;
setTheme(next);
setIcon(icon, next);
setActive(next);
const ok = await updateTheme(menu, next);
if (!ok) {
setTheme(current);
setIcon(icon, current);
setActive(current);
}
});
});
};
const initThemeToggle = () => {
const button = document.querySelector('[data-theme-toggle]');
const button = optionalEl('[data-theme-toggle]');
if (!button) return;
const root = document.documentElement;
const getCurrent = () => (root.dataset.theme === 'dark' ? 'dark' : 'light');
setIcon(button, getCurrent());
const icon = button.querySelector('i');
const getCurrent = () => document.documentElement.dataset.theme || '';
setIcon(icon, getCurrent());
button.addEventListener('click', async () => {
const current = getCurrent();
const next = current === 'dark' ? 'light' : 'dark';
const next = isDarkTheme(current) ? 'light' : 'dark';
setTheme(next);
setIcon(button, next);
setIcon(icon, next);
const ok = await updateTheme(button, next);
if (!ok) {
setTheme(current);
setIcon(button, current);
setIcon(icon, current);
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initThemeToggle);
document.addEventListener('DOMContentLoaded', () => {
initThemeMenu();
initThemeToggle();
});
} else {
initThemeMenu();
initThemeToggle();
}

View File

@@ -1,3 +1,5 @@
import { requireEl, warnOnce } from '../core/app-dom.js';
export function initAsidePanels(options = {}) {
const {
barSelector = '.aside-icon-bar',
@@ -8,23 +10,38 @@ export function initAsidePanels(options = {}) {
persistSidebarStateOnActivate = true
} = options;
const bar = document.querySelector(barSelector);
const bar = requireEl(barSelector, { module: 'aside-panels' });
if (!bar) return;
const tabs = Array.from(bar.querySelectorAll('[data-aside-target]'));
if (!tabs.length) return;
if (!tabs.length) {
warnOnce('UI_EL_MISSING', 'No aside tabs found', { module: 'aside-panels' });
return;
}
const shortcutItems = Array.from(bar.querySelectorAll('[data-aside-target], [data-aside-shortcut]'));
const panels = Array.from(document.querySelectorAll(panelSelector));
if (!panels.length) {
warnOnce('UI_EL_MISSING', 'No aside panels found', { module: 'aside-panels' });
}
const titleEl = document.querySelector(titleSelector);
const toolsHost = document.querySelector(toolsSelector);
const sidebarController = window.AppSidebar || null;
const defaultTitle = titleEl?.dataset.asideTitleDefault || titleEl?.textContent || '';
const storageKey = bar.dataset.asideStorage || '';
const isMac = /(Mac|iPhone|iPad|iPod)/i.test(navigator.platform || navigator.userAgent);
const shortcutPrefix = isMac ? '⌘' : 'Ctrl+';
const getPanelKey = (panel) => panel?.dataset?.asidePanel || '';
const getTabKey = (tab) => tab?.dataset?.asideTarget || '';
const getPanelTools = (panel) => panel?.querySelector('[data-aside-panel-tools]') || null;
const getTabHref = (tab) => tab?.dataset?.asideHref || '';
const isEditableTarget = (target) => {
if (!target) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
const initPanelDetails = (panel) => {
if (!panel || panel.dataset.detailsBound === '1') return;
@@ -101,6 +118,9 @@ export function initAsidePanels(options = {}) {
const openSidebar = (persist) => {
if (!revealSidebarOnActivate || !sidebarController) return;
if (typeof sidebarController.isHidden === 'function' && sidebarController.isHidden()) {
sidebarController.show({ persist });
}
if (!sidebarController.isCollapsed()) return;
sidebarController.open({ persist });
};
@@ -152,6 +172,19 @@ export function initAsidePanels(options = {}) {
};
setActive(getInitialKey(), { fromUser: false });
document.documentElement.classList.remove('aside-pending');
window.AppAsidePanels = {
setActive,
open: (key) => setActive(key, { fromUser: true })
};
shortcutItems.forEach((item, idx) => {
const base = item.dataset.tooltipBase || item.getAttribute('data-tooltip') || item.getAttribute('aria-label') || '';
if (!base) return;
const shortcutKey = idx === 9 ? '0' : String(idx + 1);
item.dataset.tooltipBase = base;
item.setAttribute('data-tooltip', `${base} (${shortcutPrefix}${shortcutKey})`);
});
tabs.forEach((tab) => {
tab.addEventListener('click', (event) => {
@@ -176,12 +209,29 @@ export function initAsidePanels(options = {}) {
return;
}
if (isActive && sidebarController) {
sidebarController.close({ persist: true });
if (sidebarController.isHidden?.()) {
sidebarController.show({ persist: true });
} else if (typeof sidebarController.hide === 'function') {
sidebarController.hide({ persist: true });
}
return;
}
setActive(key, { fromUser: true });
});
});
document.addEventListener('keydown', (event) => {
if (!(event.metaKey || event.ctrlKey)) return;
if (event.altKey) return;
if (isEditableTarget(event.target)) return;
const key = event.key;
if (!/^[0-9]$/.test(key)) return;
const index = key === '0' ? 9 : (Number.parseInt(key, 10) - 1);
if (index < 0 || index >= shortcutItems.length) return;
event.preventDefault();
shortcutItems[index].click();
});
}
if (document.readyState === 'loading') {

View File

@@ -1,3 +1,5 @@
import { optionalEl, warnOnce } from '../core/app-dom.js';
export function initDetailsAsideToggle(options = {}) {
const {
buttonSelector = '#toggle-main-content-aside',
@@ -6,12 +8,15 @@ export function initDetailsAsideToggle(options = {}) {
storageKey = 'app-details-aside-collapsed'
} = options;
const button = document.querySelector(buttonSelector);
const aside = document.querySelector(asideSelector);
const button = optionalEl(buttonSelector);
const aside = optionalEl(asideSelector);
if (!button || !aside) return;
const container = aside.closest(containerSelector) || document.querySelector(containerSelector);
if (!container) return;
if (!container) {
warnOnce('UI_EL_MISSING', `Missing details container: ${containerSelector}`, { module: 'details-aside' });
return;
}
if (!button.getAttribute('aria-controls')) {
button.setAttribute('aria-controls', aside.id || 'app-details-aside-section');

View File

@@ -1,15 +1,21 @@
import { requireEl, warnOnce } from '../core/app-dom.js';
export function initSidebarToggle(options = {}) {
const {
buttonSelector = '#toggle-aside',
buttonSelector = '[data-sidebar-toggle]',
asideSelector = '#app-sidebar',
containerSelector = '.app-container',
storageKey = 'app-sidebar-collapsed'
storageKey = 'app-sidebar-collapsed',
hiddenStorageKey = 'app-sidebar-hidden'
} = options;
const aside = document.querySelector(asideSelector);
const aside = requireEl(asideSelector, { module: 'sidebar-toggle' });
if (!aside) return null;
const buttons = Array.from(document.querySelectorAll(buttonSelector));
if (!buttons.length) {
warnOnce('UI_EL_MISSING', `Missing sidebar toggle buttons: ${buttonSelector}`, { module: 'sidebar-toggle' });
}
const container = document.querySelector(containerSelector);
const root = document.documentElement;
@@ -21,6 +27,14 @@ export function initSidebarToggle(options = {}) {
}
};
const readHiddenStored = () => {
try {
return window.localStorage.getItem(hiddenStorageKey);
} catch (error) {
return null;
}
};
const writeStored = (collapsed) => {
try {
window.localStorage.setItem(storageKey, collapsed ? '1' : '0');
@@ -29,6 +43,14 @@ export function initSidebarToggle(options = {}) {
}
};
const writeHiddenStored = (hidden) => {
try {
window.localStorage.setItem(hiddenStorageKey, hidden ? '1' : '0');
} catch (error) {
// ignore storage errors
}
};
const applyState = (collapsed, { persist = false } = {}) => {
aside.classList.toggle('is-collapsed', collapsed);
if (container) {
@@ -48,7 +70,27 @@ export function initSidebarToggle(options = {}) {
}
};
const applyHidden = (hidden, { persist = false } = {}) => {
aside.classList.toggle('is-hidden', hidden);
if (container) {
container.classList.toggle('is-sidebar-hidden', hidden);
}
if (root) {
root.classList.toggle('sidebar-hidden', hidden);
}
buttons.forEach((button) => {
if (!button.getAttribute('aria-controls')) {
button.setAttribute('aria-controls', aside.id || 'app-sidebar');
}
button.setAttribute('aria-expanded', hidden ? 'false' : button.getAttribute('aria-expanded') || 'true');
});
if (persist) {
writeHiddenStored(hidden);
}
};
const isCollapsed = () => aside.classList.contains('is-collapsed');
const isHidden = () => aside.classList.contains('is-hidden');
const getInitialState = () => {
const stored = readStored();
@@ -60,17 +102,31 @@ export function initSidebarToggle(options = {}) {
const controller = {
isCollapsed,
isHidden,
open: ({ persist = true } = {}) => applyState(false, { persist }),
close: ({ persist = true } = {}) => applyState(true, { persist }),
toggle: ({ persist = true } = {}) => applyState(!isCollapsed(), { persist }),
apply: (collapsed, { persist = false } = {}) => applyState(!!collapsed, { persist })
apply: (collapsed, { persist = false } = {}) => applyState(!!collapsed, { persist }),
show: ({ persist = true } = {}) => applyHidden(false, { persist }),
hide: ({ persist = true } = {}) => applyHidden(true, { persist }),
toggleVisibility: ({ persist = true } = {}) => applyHidden(!isHidden(), { persist }),
applyVisibility: (hidden, { persist = false } = {}) => applyHidden(!!hidden, { persist })
};
applyState(getInitialState(), { persist: false });
const hiddenStored = readHiddenStored();
if (hiddenStored === '1' || hiddenStored === '0') {
applyHidden(hiddenStored === '1', { persist: false });
}
buttons.forEach((button) => {
button.addEventListener('click', (event) => {
event.preventDefault();
const action = (button.dataset.sidebarAction || '').toLowerCase();
if (action === 'visibility') {
controller.toggleVisibility({ persist: true });
return;
}
controller.toggle({ persist: true });
});
});
@@ -82,12 +138,32 @@ export function initSidebarToggle(options = {}) {
if (action === 'open') controller.open({ persist });
if (action === 'close') controller.close({ persist });
if (action === 'toggle') controller.toggle({ persist });
if (action === 'show') controller.show({ persist });
if (action === 'hide') controller.hide({ persist });
if (action === 'toggle-visibility') controller.toggleVisibility({ persist });
});
window.AppSidebar = controller;
return controller;
}
const isEditableTarget = (target) => {
if (!target) return false;
if (target.isContentEditable) return true;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT';
};
document.addEventListener('keydown', (event) => {
if (!(event.metaKey || event.ctrlKey)) return;
if (event.altKey) return;
if (event.key.toLowerCase() !== 'b') return;
if (isEditableTarget(event.target)) return;
if (!window.AppSidebar || typeof window.AppSidebar.toggleVisibility !== 'function') return;
event.preventDefault();
window.AppSidebar.toggleVisibility({ persist: true });
});
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => initSidebarToggle());
} else {