baseline
This commit is contained in:
89
web/js/components/app-async-flash.js
Normal file
89
web/js/components/app-async-flash.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* Async Flash Messages & Loading State
|
||||
* Renders flash messages into #async-messages for JS-triggered notifications
|
||||
* Provides loading cursor state management
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set global loading state (cursor + pointer-events)
|
||||
* @param {boolean} loading - Whether to show loading state
|
||||
*/
|
||||
export function setLoading(loading) {
|
||||
if (loading) {
|
||||
document.body.classList.add('is-loading');
|
||||
} else {
|
||||
document.body.classList.remove('is-loading');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute an async function with loading state
|
||||
* @param {Function} fn - Async function to execute
|
||||
* @returns {Promise<any>} - Result of the function
|
||||
*/
|
||||
export async function withLoading(fn) {
|
||||
setLoading(true);
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultTimeouts = {
|
||||
success: 10000,
|
||||
info: 10000,
|
||||
warning: 10000,
|
||||
error: 0
|
||||
};
|
||||
|
||||
/**
|
||||
* Show an async flash message
|
||||
* @param {string} type - Message type: 'success', 'info', 'warning', 'error'
|
||||
* @param {string} message - The message text
|
||||
* @param {number} [timeout] - Auto-dismiss timeout in ms (0 = no auto-dismiss)
|
||||
*/
|
||||
export function showAsyncFlash(type, message, timeout) {
|
||||
const container = document.getElementById('async-messages');
|
||||
if (!container) {
|
||||
console.warn('async-messages container not found');
|
||||
return null;
|
||||
}
|
||||
|
||||
const effectiveTimeout = timeout ?? defaultTimeouts[type] ?? 5000;
|
||||
|
||||
const notice = document.createElement('div');
|
||||
notice.className = 'notice';
|
||||
notice.setAttribute('data-variant', type);
|
||||
|
||||
const messageSpan = document.createElement('span');
|
||||
messageSpan.textContent = message;
|
||||
notice.appendChild(messageSpan);
|
||||
|
||||
const closeButton = document.createElement('button');
|
||||
closeButton.type = 'button';
|
||||
closeButton.className = 'contrast outline small';
|
||||
closeButton.innerHTML = '<i class="bi bi-x"></i>';
|
||||
closeButton.addEventListener('click', () => notice.remove());
|
||||
notice.appendChild(closeButton);
|
||||
|
||||
container.appendChild(notice);
|
||||
|
||||
if (effectiveTimeout > 0) {
|
||||
notice.style.setProperty('--flash-timeout', `${effectiveTimeout}ms`);
|
||||
notice.classList.add('flash-timed');
|
||||
setTimeout(() => notice.remove(), effectiveTimeout);
|
||||
}
|
||||
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all async flash messages
|
||||
*/
|
||||
export function clearAsyncFlash() {
|
||||
const container = document.getElementById('async-messages');
|
||||
if (container) {
|
||||
container.innerHTML = '';
|
||||
}
|
||||
}
|
||||
26
web/js/components/app-bulk-selection.js
Normal file
26
web/js/components/app-bulk-selection.js
Normal file
@@ -0,0 +1,26 @@
|
||||
export function bindBulkVisibility({
|
||||
containerSelector,
|
||||
buttonSelector,
|
||||
selectedRowSelector = '.gridjs-tr-selected'
|
||||
} = {}) {
|
||||
const container = document.querySelector(containerSelector);
|
||||
const buttons = Array.from(document.querySelectorAll(buttonSelector));
|
||||
if (!container || !buttons.length) return { update: () => {} };
|
||||
|
||||
const update = () => {
|
||||
const selected = container.querySelectorAll(selectedRowSelector).length > 0;
|
||||
buttons.forEach((button) => {
|
||||
button.hidden = !selected;
|
||||
});
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(() => update());
|
||||
observer.observe(container, {
|
||||
subtree: true,
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
update();
|
||||
return { update, destroy: () => observer.disconnect() };
|
||||
}
|
||||
43
web/js/components/app-color-default-toggle.js
Normal file
43
web/js/components/app-color-default-toggle.js
Normal file
@@ -0,0 +1,43 @@
|
||||
const toggles = document.querySelectorAll('[data-color-default-toggle]');
|
||||
|
||||
const syncToggle = (toggle) => {
|
||||
const targetSelector = toggle.dataset.colorTarget || '';
|
||||
if (!targetSelector) {
|
||||
return;
|
||||
}
|
||||
const target = document.querySelector(targetSelector);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
const defaultValue = toggle.dataset.colorDefault || '#2fa4a4';
|
||||
if (toggle.checked) {
|
||||
if (!target.dataset.customValue) {
|
||||
target.dataset.customValue = target.value;
|
||||
}
|
||||
target.value = defaultValue;
|
||||
target.setAttribute('disabled', 'disabled');
|
||||
} else {
|
||||
target.removeAttribute('disabled');
|
||||
if (target.dataset.customValue) {
|
||||
target.value = target.dataset.customValue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
toggles.forEach((toggle) => {
|
||||
const targetSelector = toggle.dataset.colorTarget || '';
|
||||
const target = targetSelector ? document.querySelector(targetSelector) : null;
|
||||
|
||||
toggle.addEventListener('change', () => syncToggle(toggle));
|
||||
|
||||
if (target) {
|
||||
target.addEventListener('input', () => {
|
||||
if (toggle.checked) {
|
||||
toggle.checked = false;
|
||||
target.removeAttribute('disabled');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
syncToggle(toggle);
|
||||
});
|
||||
66
web/js/components/app-copy-badge.js
Normal file
66
web/js/components/app-copy-badge.js
Normal file
@@ -0,0 +1,66 @@
|
||||
const COPY_SELECTOR = '.badge[data-copy="true"]';
|
||||
const COPIED_CLASS = 'is-copied';
|
||||
|
||||
const getCopyValue = (badge) => {
|
||||
const explicit = badge.getAttribute('data-copy-value');
|
||||
if (explicit !== null && explicit !== '') {
|
||||
return explicit;
|
||||
}
|
||||
return (badge.textContent || '').trim();
|
||||
};
|
||||
|
||||
const copyText = async (value) => {
|
||||
if (!value) return false;
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(value);
|
||||
return true;
|
||||
}
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = value;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
};
|
||||
|
||||
const enhanceBadge = (badge) => {
|
||||
if (badge.querySelector('.badge-copy-icon')) return;
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'badge-copy-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
icon.innerHTML = '<i class="bi bi-copy"></i><i class="bi bi-check2"></i>';
|
||||
badge.appendChild(icon);
|
||||
badge.setAttribute('role', 'button');
|
||||
badge.setAttribute('tabindex', '0');
|
||||
if (!badge.hasAttribute('aria-label')) {
|
||||
badge.setAttribute('aria-label', 'Copy to clipboard');
|
||||
}
|
||||
const handler = async (event) => {
|
||||
event.preventDefault();
|
||||
const value = getCopyValue(badge);
|
||||
const ok = await copyText(value);
|
||||
if (!ok) return;
|
||||
badge.classList.add(COPIED_CLASS);
|
||||
window.setTimeout(() => badge.classList.remove(COPIED_CLASS), 1200);
|
||||
};
|
||||
badge.addEventListener('click', handler);
|
||||
badge.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
handler(event);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const initBadgeCopy = () => {
|
||||
document.querySelectorAll(COPY_SELECTOR).forEach(enhanceBadge);
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initBadgeCopy);
|
||||
} else {
|
||||
initBadgeCopy();
|
||||
}
|
||||
53
web/js/components/app-flash-auto-dismiss.js
Normal file
53
web/js/components/app-flash-auto-dismiss.js
Normal file
@@ -0,0 +1,53 @@
|
||||
export function initFlashAutoDismiss(options = {}) {
|
||||
const {
|
||||
selector = '.flash-stack .notice[data-flash-timeout]',
|
||||
defaultTimeout = 0
|
||||
} = options;
|
||||
|
||||
const notices = document.querySelectorAll(selector);
|
||||
if (!notices.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const postForm = async (form) => {
|
||||
const action = form.getAttribute('action');
|
||||
if (!action) return null;
|
||||
const body = new URLSearchParams(new FormData(form));
|
||||
return fetch(action, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
};
|
||||
|
||||
notices.forEach((notice) => {
|
||||
const timeout = Number.parseInt(
|
||||
notice.dataset.flashTimeout || `${defaultTimeout}`,
|
||||
10
|
||||
);
|
||||
if (!timeout || timeout <= 0) {
|
||||
return;
|
||||
}
|
||||
notice.style.setProperty('--flash-timeout', `${timeout}ms`);
|
||||
notice.classList.add('flash-timed');
|
||||
window.setTimeout(async () => {
|
||||
const form = notice.querySelector('form');
|
||||
if (form) {
|
||||
const response = await postForm(form);
|
||||
if (!response || !response.ok) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
notice.remove();
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initFlashAutoDismiss());
|
||||
} else {
|
||||
initFlashAutoDismiss();
|
||||
}
|
||||
48
web/js/components/app-fslightbox-refresh.js
Normal file
48
web/js/components/app-fslightbox-refresh.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const refreshFsLightbox = () => {
|
||||
if (typeof window.refreshFsLightbox === 'function') {
|
||||
window.refreshFsLightbox();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
let refreshTimer = null;
|
||||
const scheduleRefresh = () => {
|
||||
if (refreshTimer) return;
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = null;
|
||||
refreshFsLightbox();
|
||||
}, 30);
|
||||
};
|
||||
|
||||
const initObserver = () => {
|
||||
if (!document.body || typeof MutationObserver === 'undefined') {
|
||||
return;
|
||||
}
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (!(node instanceof HTMLElement)) continue;
|
||||
if (node.matches?.('[data-fslightbox]') || node.querySelector?.('[data-fslightbox]')) {
|
||||
scheduleRefresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
};
|
||||
|
||||
if (!refreshFsLightbox()) {
|
||||
scheduleRefresh();
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
refreshFsLightbox();
|
||||
initObserver();
|
||||
});
|
||||
} else {
|
||||
refreshFsLightbox();
|
||||
initObserver();
|
||||
}
|
||||
168
web/js/components/app-global-search.js
Normal file
168
web/js/components/app-global-search.js
Normal file
@@ -0,0 +1,168 @@
|
||||
const input = document.querySelector('#side-search');
|
||||
const resultsEl = document.querySelector('[data-global-search-results]');
|
||||
|
||||
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 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 itemsByKey = new Map();
|
||||
resultsEl.querySelectorAll('[data-search-key]').forEach((item) => {
|
||||
const key = item.getAttribute('data-search-key');
|
||||
if (key) itemsByKey.set(key, item);
|
||||
});
|
||||
if (itemsByKey.size === 0) {
|
||||
console.warn('[GlobalSearch] No [data-search-key] elements found in results container');
|
||||
}
|
||||
|
||||
const setLoadingState = (value) => {
|
||||
resultsEl.querySelectorAll('[data-search-count]').forEach((el) => {
|
||||
el.textContent = value;
|
||||
});
|
||||
};
|
||||
|
||||
const render = (items, query) => {
|
||||
items.forEach((item) => {
|
||||
const li = itemsByKey.get(item.key);
|
||||
if (!li) {
|
||||
console.warn('[GlobalSearch] No element found for key:', item.key);
|
||||
return;
|
||||
}
|
||||
const countEl = li.querySelector('[data-search-count]');
|
||||
const link = li.querySelector('a');
|
||||
const previewEl = li.querySelector('[data-search-preview]');
|
||||
const base = item.key === 'pages' && item.url ? item.url : (li.getAttribute('data-search-base') || item.url);
|
||||
const url = new URL(base, appBase);
|
||||
if (item.key !== 'pages') {
|
||||
url.searchParams.set('search', query);
|
||||
}
|
||||
if (countEl) countEl.textContent = item.count.toString();
|
||||
if (link) {
|
||||
link.href = item.count > 0 ? url.toString() : base;
|
||||
link.setAttribute('aria-disabled', item.count > 0 ? 'false' : 'true');
|
||||
}
|
||||
if (previewEl) {
|
||||
previewEl.innerHTML = '';
|
||||
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);
|
||||
return;
|
||||
}
|
||||
const previewLi = document.createElement('li');
|
||||
const previewLink = document.createElement('a');
|
||||
previewLink.href = previewItem.url;
|
||||
previewLink.textContent = previewItem.label || '';
|
||||
previewLi.appendChild(previewLink);
|
||||
previewEl.appendChild(previewLi);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (item.count > 0) {
|
||||
li.hidden = false;
|
||||
} else {
|
||||
li.hidden = true;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setLoadingState('0');
|
||||
resultsEl.querySelectorAll('a').forEach((link) => {
|
||||
link.setAttribute('aria-disabled', 'true');
|
||||
const base = link.closest('[data-search-base]')?.getAttribute('data-search-base');
|
||||
link.setAttribute('href', base || '#');
|
||||
});
|
||||
resultsEl.querySelectorAll('[data-search-preview]').forEach((list) => {
|
||||
list.innerHTML = '';
|
||||
});
|
||||
resultsEl.querySelectorAll('[data-search-key]').forEach((li) => {
|
||||
li.hidden = false;
|
||||
});
|
||||
};
|
||||
|
||||
const fetchCounts = async (value) => {
|
||||
const url = new URL(endpoint.toString());
|
||||
url.searchParams.set('q', value);
|
||||
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);
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
if (Array.isArray(data)) {
|
||||
render(data, value);
|
||||
} else {
|
||||
console.warn('[GlobalSearch] Invalid response format, expected array but got:', typeof data, data);
|
||||
clear();
|
||||
}
|
||||
};
|
||||
|
||||
const onInput = () => {
|
||||
const value = input.value.trim();
|
||||
if (value.length < minLength) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
if (pending) window.clearTimeout(pending);
|
||||
pending = window.setTimeout(() => {
|
||||
fetchCounts(value).catch((err) => {
|
||||
console.warn('[GlobalSearch] Fetch error:', err);
|
||||
clear();
|
||||
});
|
||||
}, 300);
|
||||
};
|
||||
|
||||
input.addEventListener('input', onInput);
|
||||
const submitSearch = () => {
|
||||
const value = input.value.trim();
|
||||
if (value.length < minLength) return;
|
||||
resultsPage.searchParams.set('search', value);
|
||||
window.location.href = resultsPage.toString();
|
||||
};
|
||||
|
||||
input.addEventListener('keydown', (event) => {
|
||||
if (event.key !== 'Enter') return;
|
||||
event.preventDefault();
|
||||
submitSearch();
|
||||
});
|
||||
|
||||
const form = input.closest('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
submitSearch();
|
||||
});
|
||||
}
|
||||
|
||||
if (toggleButton && panel) {
|
||||
toggleButton.addEventListener('click', () => {
|
||||
panel.classList.toggle('search-details-hidden');
|
||||
if (toggleIcon) {
|
||||
toggleIcon.classList.toggle('bi-dash-square');
|
||||
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
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const preset = currentUrl.searchParams.get('search');
|
||||
if (preset && !input.value) {
|
||||
input.value = preset;
|
||||
onInput();
|
||||
}
|
||||
}
|
||||
109
web/js/components/app-multiselect-init.js
Normal file
109
web/js/components/app-multiselect-init.js
Normal file
@@ -0,0 +1,109 @@
|
||||
let multiSelectLoader = null;
|
||||
|
||||
const resolveScriptUrl = (script) => {
|
||||
if (script) return script;
|
||||
const assetBase = window.APP_ASSET_BASE || document.baseURI;
|
||||
return new URL('vendor/multi-select/MultiSelect.js', assetBase).toString();
|
||||
};
|
||||
|
||||
const ensureMultiSelect = async (script) => {
|
||||
if (typeof window.MultiSelect !== 'undefined') {
|
||||
return window.MultiSelect;
|
||||
}
|
||||
if (!multiSelectLoader) {
|
||||
multiSelectLoader = new Promise((resolve, reject) => {
|
||||
const tag = document.createElement('script');
|
||||
tag.src = resolveScriptUrl(script);
|
||||
tag.async = true;
|
||||
tag.onload = () => resolve(window.MultiSelect);
|
||||
tag.onerror = (event) => reject(event);
|
||||
document.head.appendChild(tag);
|
||||
});
|
||||
}
|
||||
return multiSelectLoader;
|
||||
};
|
||||
|
||||
export const initMultiSelect = async (target = '[data-multi-select]') => {
|
||||
const elements = typeof target === 'string' ? document.querySelectorAll(target) : [target];
|
||||
if (!elements.length) return [];
|
||||
|
||||
const MultiSelect = await ensureMultiSelect();
|
||||
const instances = [];
|
||||
elements.forEach((element) => {
|
||||
if (!element || element.dataset.multiSelectInit === '1') return;
|
||||
const syncTarget = element.dataset.syncTarget;
|
||||
const syncInput = syncTarget ? document.querySelector(syncTarget) : null;
|
||||
const options = {};
|
||||
const placeholder = element.dataset.placeholder;
|
||||
if (placeholder) options.placeholder = placeholder;
|
||||
if (element.dataset.search !== undefined) {
|
||||
options.search = element.dataset.search === 'true';
|
||||
}
|
||||
if (element.dataset.selectAll !== undefined) {
|
||||
options.selectAll = element.dataset.selectAll === 'true';
|
||||
}
|
||||
if (element.dataset.disabled !== undefined) {
|
||||
options.disabled = element.dataset.disabled === 'true';
|
||||
}
|
||||
if (element.disabled) {
|
||||
options.disabled = true;
|
||||
}
|
||||
if (element.dataset.listAll !== undefined) {
|
||||
options.listAll = element.dataset.listAll === 'true';
|
||||
}
|
||||
if (element.dataset.selectedLabel) {
|
||||
options.selectedLabel = element.dataset.selectedLabel;
|
||||
}
|
||||
if (element.dataset.searchPlaceholder) {
|
||||
options.searchPlaceholder = element.dataset.searchPlaceholder;
|
||||
}
|
||||
if (element.dataset.selectAllLabel) {
|
||||
options.selectAllLabel = element.dataset.selectAllLabel;
|
||||
}
|
||||
let isSyncing = false;
|
||||
let instance;
|
||||
try {
|
||||
instance = new MultiSelect(element, options);
|
||||
} catch (error) {
|
||||
console.error('[multi-select] init failed', element, error);
|
||||
return;
|
||||
}
|
||||
if (element.dataset.disabled === 'true' || element.disabled) {
|
||||
instance.disable();
|
||||
}
|
||||
element.dataset.multiSelectInit = '1';
|
||||
instances.push(instance);
|
||||
if (syncInput) {
|
||||
syncInput._multiSelectInstance = instance;
|
||||
const syncValues = () => {
|
||||
if (!syncInput || isSyncing) return;
|
||||
isSyncing = true;
|
||||
const values = instance.selectedValues || [];
|
||||
syncInput.value = Array.isArray(values) ? values.join(',') : '';
|
||||
syncInput.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
isSyncing = false;
|
||||
};
|
||||
instance.element.addEventListener('multi-select:change', syncValues);
|
||||
const values = instance.selectedValues || [];
|
||||
syncInput.value = Array.isArray(values) ? values.join(',') : '';
|
||||
syncInput.addEventListener('change', () => {
|
||||
if (isSyncing) return;
|
||||
const nextValues = syncInput.value
|
||||
? syncInput.value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
: [];
|
||||
instance.setValues(nextValues);
|
||||
});
|
||||
}
|
||||
});
|
||||
return instances;
|
||||
};
|
||||
|
||||
const autoInit = () => {
|
||||
initMultiSelect().catch(() => {});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', autoInit);
|
||||
} else {
|
||||
autoInit();
|
||||
}
|
||||
48
web/js/components/app-nav-history.js
Normal file
48
web/js/components/app-nav-history.js
Normal file
@@ -0,0 +1,48 @@
|
||||
const updateHistoryButtons = () => {
|
||||
const back = document.querySelector('#global-back');
|
||||
const forward = document.querySelector('#global-forward');
|
||||
if (!back && !forward) return;
|
||||
|
||||
const hasHistory = window.history.length > 1;
|
||||
|
||||
if (back) {
|
||||
back.setAttribute('aria-disabled', hasHistory ? 'false' : 'true');
|
||||
back.classList.toggle('is-disabled', !hasHistory);
|
||||
}
|
||||
if (forward) {
|
||||
forward.setAttribute('aria-disabled', hasHistory ? 'false' : 'true');
|
||||
forward.classList.toggle('is-disabled', !hasHistory);
|
||||
}
|
||||
};
|
||||
|
||||
const initHistoryButtons = () => {
|
||||
const back = document.querySelector('#global-back');
|
||||
const forward = document.querySelector('#global-forward');
|
||||
|
||||
if (back) {
|
||||
back.addEventListener('click', (event) => {
|
||||
if (window.history.length > 1) {
|
||||
event.preventDefault();
|
||||
window.history.back();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (forward) {
|
||||
forward.addEventListener('click', (event) => {
|
||||
if (window.history.length > 1) {
|
||||
event.preventDefault();
|
||||
window.history.forward();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
updateHistoryButtons();
|
||||
window.addEventListener('popstate', updateHistoryButtons);
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initHistoryButtons);
|
||||
} else {
|
||||
initHistoryButtons();
|
||||
}
|
||||
185
web/js/components/app-page-editor.js
Normal file
185
web/js/components/app-page-editor.js
Normal file
@@ -0,0 +1,185 @@
|
||||
import EditorJS from '../../vendor/editorjs/editorjs.mjs';
|
||||
|
||||
const form = document.querySelector('[data-page-editor]');
|
||||
if (form) {
|
||||
const holder = form.querySelector('[data-editor-holder]');
|
||||
if (!holder) {
|
||||
// No holder available, skip initialization.
|
||||
} else {
|
||||
const contentField = form.querySelector('#page-content');
|
||||
const toggleButton = document.querySelector('[data-editor-toggle]');
|
||||
const saveButton = document.querySelector('[data-editor-save]');
|
||||
const canEdit = form.dataset.canEdit === '1';
|
||||
|
||||
const parseData = (value) => {
|
||||
if (!value) {
|
||||
return { blocks: [] };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
if (!Array.isArray(parsed.blocks)) {
|
||||
parsed.blocks = [];
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
return { blocks: [] };
|
||||
};
|
||||
|
||||
const setToggleLabel = (mode) => {
|
||||
if (!toggleButton) {
|
||||
return;
|
||||
}
|
||||
const viewLabel = toggleButton.dataset.viewLabel || '';
|
||||
const editLabel = toggleButton.dataset.editLabel || '';
|
||||
const text = mode === 'edit' ? viewLabel : editLabel;
|
||||
const icon = toggleButton.querySelector('[data-editor-icon]');
|
||||
|
||||
if (icon) {
|
||||
icon.className = mode === 'edit' ? 'bi bi-eye-fill' : 'bi bi-pencil-square';
|
||||
}
|
||||
toggleButton.setAttribute('aria-label', text);
|
||||
toggleButton.dataset.tooltip = text;
|
||||
};
|
||||
|
||||
let mode = form.dataset.editMode === 'edit' && canEdit ? 'edit' : 'view';
|
||||
form.dataset.editMode = mode;
|
||||
setToggleLabel(mode);
|
||||
|
||||
const HeaderTool = window.Header || window.EditorJSHeader || null;
|
||||
const ListTool = window.EditorjsList || window.List || null;
|
||||
const ChecklistTool = window.Checklist || window.EditorJSChecklist || null;
|
||||
const TableTool = window.Table || window.EditorJSTable || null;
|
||||
const ColumnsTool = window.editorjsColumns || window.Columns || null;
|
||||
const MarkerTool = window.Marker || null;
|
||||
|
||||
const tools = {};
|
||||
if (HeaderTool) {
|
||||
tools.header = {
|
||||
class: HeaderTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
placeholder: form.dataset.headerPlaceholder || 'Heading',
|
||||
levels: [1, 2, 3, 4],
|
||||
defaultLevel: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ListTool) {
|
||||
tools.list = {
|
||||
class: ListTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
defaultStyle: 'unordered',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ChecklistTool) {
|
||||
tools.checklist = {
|
||||
class: ChecklistTool,
|
||||
inlineToolbar: true,
|
||||
};
|
||||
}
|
||||
if (TableTool) {
|
||||
tools.table = {
|
||||
class: TableTool,
|
||||
inlineToolbar: true,
|
||||
config: {
|
||||
rows: 2,
|
||||
cols: 2,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (ColumnsTool) {
|
||||
const innerTools = { ...tools };
|
||||
tools.columns = {
|
||||
class: ColumnsTool,
|
||||
config: {
|
||||
EditorJsLibrary: EditorJS,
|
||||
tools: innerTools,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (MarkerTool) {
|
||||
tools.marker = {
|
||||
class: MarkerTool,
|
||||
shortcut: 'CMD+SHIFT+M',
|
||||
};
|
||||
}
|
||||
|
||||
const inlineTools = ['bold', 'italic', 'link'];
|
||||
if (MarkerTool) {
|
||||
inlineTools.unshift('marker');
|
||||
}
|
||||
|
||||
const editor = new EditorJS({
|
||||
holder,
|
||||
data: parseData(contentField ? contentField.value : ''),
|
||||
readOnly: mode !== 'edit',
|
||||
autofocus: mode === 'edit',
|
||||
placeholder: form.dataset.placeholder || undefined,
|
||||
tools,
|
||||
inlineToolbar: inlineTools,
|
||||
});
|
||||
|
||||
const toggleMode = async (nextMode) => {
|
||||
if (!canEdit || mode === nextMode) {
|
||||
return;
|
||||
}
|
||||
mode = nextMode;
|
||||
form.dataset.editMode = mode;
|
||||
setToggleLabel(mode);
|
||||
if (editor.readOnly && editor.readOnly.toggle) {
|
||||
await editor.readOnly.toggle(mode !== 'edit');
|
||||
}
|
||||
if (saveButton) {
|
||||
saveButton.disabled = mode !== 'edit';
|
||||
}
|
||||
};
|
||||
|
||||
if (toggleButton && canEdit) {
|
||||
toggleButton.addEventListener('click', () => {
|
||||
toggleMode(mode === 'edit' ? 'view' : 'edit');
|
||||
});
|
||||
}
|
||||
|
||||
if (form && contentField && canEdit) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
if (form.dataset.submitting === '1') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const output = await editor.save();
|
||||
contentField.value = JSON.stringify(output);
|
||||
form.dataset.submitting = '1';
|
||||
|
||||
try {
|
||||
const formData = new FormData(form);
|
||||
const response = await fetch(form.action || window.location.pathname, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
Accept: 'application/json',
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
if (response.redirected) {
|
||||
window.location.href = response.url;
|
||||
return;
|
||||
}
|
||||
const data = await response.json().catch(() => null);
|
||||
if (data && data.redirect) {
|
||||
window.location.href = data.redirect;
|
||||
return;
|
||||
}
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
76
web/js/components/app-password-hints.js
Normal file
76
web/js/components/app-password-hints.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const rules = {
|
||||
min: (value, min) => value.length >= min,
|
||||
upper: (value) => /[A-Z]/.test(value),
|
||||
lower: (value) => /[a-z]/.test(value),
|
||||
number: (value) => /\d/.test(value),
|
||||
symbol: (value) => /[^a-zA-Z0-9]/.test(value),
|
||||
email: (value, _min, email) => {
|
||||
if (!email) return true;
|
||||
return !value.toLowerCase().includes(email.toLowerCase());
|
||||
},
|
||||
match: (_value, _min, _email, confirm) => {
|
||||
if (!confirm) return false;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
export function initPasswordHints() {
|
||||
const containers = document.querySelectorAll('[data-password-hints]');
|
||||
if (!containers.length) return;
|
||||
|
||||
containers.forEach((container) => {
|
||||
const passwordSelector = container.dataset.passwordInput;
|
||||
const confirmSelector = container.dataset.confirmInput;
|
||||
const emailSelector = container.dataset.emailInput;
|
||||
const passwordInput = passwordSelector ? document.querySelector(passwordSelector) : null;
|
||||
const confirmInput = confirmSelector ? document.querySelector(confirmSelector) : null;
|
||||
const emailInput = emailSelector ? document.querySelector(emailSelector) : null;
|
||||
const minLength = Number.parseInt(container.dataset.minLength || '12', 10);
|
||||
const items = container.querySelectorAll('[data-rule]');
|
||||
|
||||
const setState = (item, state) => {
|
||||
item.classList.remove('is-valid', 'is-invalid');
|
||||
if (state === 'valid') item.classList.add('is-valid');
|
||||
if (state === 'invalid') item.classList.add('is-invalid');
|
||||
};
|
||||
|
||||
const evaluate = () => {
|
||||
const password = passwordInput?.value ?? '';
|
||||
const confirm = confirmInput?.value ?? '';
|
||||
const email = emailInput?.value ?? '';
|
||||
const hasValue = password.length > 0 || confirm.length > 0;
|
||||
|
||||
items.forEach((item) => {
|
||||
const rule = item.dataset.rule;
|
||||
if (!rule) return;
|
||||
if (!hasValue) {
|
||||
setState(item, 'neutral');
|
||||
return;
|
||||
}
|
||||
if (rule === 'match') {
|
||||
if (password === '' || confirm === '') {
|
||||
setState(item, 'neutral');
|
||||
return;
|
||||
}
|
||||
setState(item, password === confirm ? 'valid' : 'invalid');
|
||||
return;
|
||||
}
|
||||
const check = rules[rule];
|
||||
if (!check) return;
|
||||
const ok = check(password, minLength, email, confirm);
|
||||
setState(item, ok ? 'valid' : 'invalid');
|
||||
});
|
||||
};
|
||||
|
||||
if (passwordInput) passwordInput.addEventListener('input', evaluate);
|
||||
if (confirmInput) confirmInput.addEventListener('input', evaluate);
|
||||
if (emailInput) emailInput.addEventListener('input', evaluate);
|
||||
evaluate();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initPasswordHints);
|
||||
} else {
|
||||
initPasswordHints();
|
||||
}
|
||||
118
web/js/components/app-tabs.js
Normal file
118
web/js/components/app-tabs.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Initializes tab navigation
|
||||
* Usage:
|
||||
* - Tab buttons: <button data-tab="system">System</button>
|
||||
* - Tab panels: <div data-tab-panel="system">...</div>
|
||||
* - Container: <div data-tabs>...</div>
|
||||
* - URL param: ?tab=system
|
||||
*/
|
||||
export function initTabs(root = document) {
|
||||
const containers = root.querySelectorAll('[data-tabs]');
|
||||
|
||||
containers.forEach((container) => {
|
||||
if (container.dataset.tabsBound === '1') return;
|
||||
container.dataset.tabsBound = '1';
|
||||
|
||||
const tabs = Array.from(container.querySelectorAll('[data-tab]'));
|
||||
const panels = Array.from(container.querySelectorAll('[data-tab-panel]'));
|
||||
|
||||
if (!tabs.length || !panels.length) return;
|
||||
|
||||
const storageKey = container.dataset.tabsStorageKey || (container.id ? `tabs:${container.id}` : null);
|
||||
const urlParamName = container.dataset.tabsParam || 'tab';
|
||||
|
||||
// Helper to get URL parameter
|
||||
const getUrlParam = (name) => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
return params.get(name);
|
||||
};
|
||||
|
||||
// Helper to set URL parameter
|
||||
const setUrlParam = (name, value) => {
|
||||
const url = new URL(window.location);
|
||||
url.searchParams.set(name, value);
|
||||
window.history.replaceState({}, '', url);
|
||||
};
|
||||
|
||||
// Get initial active tab from URL param, localStorage, or data-tab-default
|
||||
let activeTab = null;
|
||||
const urlTab = getUrlParam(urlParamName);
|
||||
if (urlTab && panels.some(p => p.dataset.tabPanel === urlTab)) {
|
||||
activeTab = urlTab;
|
||||
} else if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored && panels.some(p => p.dataset.tabPanel === stored)) {
|
||||
activeTab = stored;
|
||||
}
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
if (!activeTab) {
|
||||
const defaultTab = container.querySelector('[data-tab-default]');
|
||||
activeTab = defaultTab?.dataset.tab || tabs[0]?.dataset.tab;
|
||||
}
|
||||
|
||||
const activateTab = (tabName) => {
|
||||
tabs.forEach(tab => {
|
||||
const isActive = tab.dataset.tab === tabName;
|
||||
tab.classList.toggle('is-active', isActive);
|
||||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
});
|
||||
|
||||
panels.forEach(panel => {
|
||||
const isVisible = panel.dataset.tabPanel === tabName;
|
||||
panel.hidden = !isVisible;
|
||||
panel.setAttribute('aria-hidden', isVisible ? 'false' : 'true');
|
||||
});
|
||||
|
||||
// Update URL parameter
|
||||
setUrlParam(urlParamName, tabName);
|
||||
|
||||
// Save to localStorage
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, tabName);
|
||||
} catch (e) {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize tabs
|
||||
tabs.forEach(tab => {
|
||||
tab.setAttribute('role', 'tab');
|
||||
tab.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
activateTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
panels.forEach(panel => {
|
||||
panel.setAttribute('role', 'tabpanel');
|
||||
});
|
||||
|
||||
container.setAttribute('role', 'tablist');
|
||||
|
||||
// Activate initial tab
|
||||
if (activeTab) {
|
||||
activateTab(activeTab);
|
||||
}
|
||||
|
||||
// Listen for popstate (back/forward navigation)
|
||||
window.addEventListener('popstate', () => {
|
||||
const newTab = getUrlParam(urlParamName);
|
||||
if (newTab && panels.some(p => p.dataset.tabPanel === newTab)) {
|
||||
activateTab(newTab);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-initialize on load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initTabs());
|
||||
} else {
|
||||
initTabs();
|
||||
}
|
||||
71
web/js/components/app-tenant-switcher.js
Normal file
71
web/js/components/app-tenant-switcher.js
Normal file
@@ -0,0 +1,71 @@
|
||||
const switchTenant = async (link) => {
|
||||
const tenantId = link.dataset.switchTenant;
|
||||
const csrfKey = link.dataset.csrfKey;
|
||||
const csrfToken = link.dataset.csrfToken;
|
||||
const errorMessage = link.dataset.errorMessage || 'Failed to switch tenant';
|
||||
|
||||
if (!tenantId || !csrfKey || !csrfToken) {
|
||||
console.error('Missing tenant ID or CSRF token');
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable link during request
|
||||
link.style.pointerEvents = 'none';
|
||||
link.style.opacity = '0.5';
|
||||
|
||||
try {
|
||||
const body = new URLSearchParams({
|
||||
tenant_id: tenantId,
|
||||
[csrfKey]: csrfToken
|
||||
});
|
||||
|
||||
const response = await fetch('admin/users/switch-tenant', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
console.error('Tenant switch failed:', data.error);
|
||||
alert(errorMessage);
|
||||
link.style.pointerEvents = '';
|
||||
link.style.opacity = '';
|
||||
}
|
||||
} else {
|
||||
console.error('Server error:', response.status);
|
||||
alert(errorMessage);
|
||||
link.style.pointerEvents = '';
|
||||
link.style.opacity = '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Network error:', error);
|
||||
alert(errorMessage);
|
||||
link.style.pointerEvents = '';
|
||||
link.style.opacity = '';
|
||||
}
|
||||
};
|
||||
|
||||
const initTenantSwitcher = () => {
|
||||
const tenantLinks = document.querySelectorAll('[data-switch-tenant]');
|
||||
|
||||
tenantLinks.forEach(link => {
|
||||
link.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
switchTenant(link);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTenantSwitcher);
|
||||
} else {
|
||||
initTenantSwitcher();
|
||||
}
|
||||
57
web/js/components/app-theme-toggle.js
Normal file
57
web/js/components/app-theme-toggle.js
Normal file
@@ -0,0 +1,57 @@
|
||||
const setTheme = (theme) => {
|
||||
const root = document.documentElement;
|
||||
root.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 updateTheme = async (button, theme) => {
|
||||
const url = button.dataset.themeUrl;
|
||||
const csrfKey = button.dataset.csrfKey;
|
||||
const csrfToken = button.dataset.csrfToken;
|
||||
if (!url || !csrfKey || !csrfToken) {
|
||||
return true;
|
||||
}
|
||||
const body = new URLSearchParams({ theme, [csrfKey]: csrfToken });
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Accept': 'application/json',
|
||||
'X-Requested-With': 'fetch'
|
||||
},
|
||||
body
|
||||
});
|
||||
return response.ok;
|
||||
};
|
||||
|
||||
const initThemeToggle = () => {
|
||||
const button = document.querySelector('[data-theme-toggle]');
|
||||
if (!button) return;
|
||||
const root = document.documentElement;
|
||||
const getCurrent = () => (root.dataset.theme === 'dark' ? 'dark' : 'light');
|
||||
setIcon(button, getCurrent());
|
||||
|
||||
button.addEventListener('click', async () => {
|
||||
const current = getCurrent();
|
||||
const next = current === 'dark' ? 'light' : 'dark';
|
||||
setTheme(next);
|
||||
setIcon(button, next);
|
||||
const ok = await updateTheme(button, next);
|
||||
if (!ok) {
|
||||
setTheme(current);
|
||||
setIcon(button, current);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initThemeToggle);
|
||||
} else {
|
||||
initThemeToggle();
|
||||
}
|
||||
191
web/js/components/app-toggle-aside-panels.js
Normal file
191
web/js/components/app-toggle-aside-panels.js
Normal file
@@ -0,0 +1,191 @@
|
||||
export function initAsidePanels(options = {}) {
|
||||
const {
|
||||
barSelector = '.aside-icon-bar',
|
||||
panelSelector = '.app-sidebar-panel',
|
||||
titleSelector = '.app-sidebar-title',
|
||||
toolsSelector = '[data-aside-tools]',
|
||||
revealSidebarOnActivate = true,
|
||||
persistSidebarStateOnActivate = true
|
||||
} = options;
|
||||
|
||||
const bar = document.querySelector(barSelector);
|
||||
if (!bar) return;
|
||||
|
||||
const tabs = Array.from(bar.querySelectorAll('[data-aside-target]'));
|
||||
if (!tabs.length) return;
|
||||
|
||||
const panels = Array.from(document.querySelectorAll(panelSelector));
|
||||
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 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 initPanelDetails = (panel) => {
|
||||
if (!panel || panel.dataset.detailsBound === '1') return;
|
||||
const storageKey = panel.dataset.asideDetailsStorage || '';
|
||||
if (!storageKey) return;
|
||||
const detailsEls = Array.from(panel.querySelectorAll('details[data-details-key]'));
|
||||
if (!detailsEls.length) return;
|
||||
|
||||
const readStored = () => {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(storageKey);
|
||||
return raw ? JSON.parse(raw) : [];
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeStored = (value) => {
|
||||
try {
|
||||
const list = Array.isArray(value) ? value : [];
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(list));
|
||||
} catch (error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const applyStored = () => {
|
||||
const stored = readStored();
|
||||
if (!stored.length) return;
|
||||
detailsEls.forEach((details) => {
|
||||
details.open = stored.includes(details.dataset.detailsKey || '');
|
||||
});
|
||||
};
|
||||
|
||||
detailsEls.forEach((details) => {
|
||||
details.addEventListener('toggle', () => {
|
||||
const openKeys = detailsEls
|
||||
.filter((item) => item.open)
|
||||
.map((item) => item.dataset.detailsKey || '')
|
||||
.filter(Boolean);
|
||||
writeStored(openKeys);
|
||||
});
|
||||
});
|
||||
|
||||
applyStored();
|
||||
panel.dataset.detailsBound = '1';
|
||||
};
|
||||
|
||||
const readStored = () => {
|
||||
if (!storageKey) return null;
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStored = (key) => {
|
||||
if (!storageKey) return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, key);
|
||||
} catch (error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const syncTools = (panel) => {
|
||||
if (!toolsHost) return;
|
||||
toolsHost.innerHTML = '';
|
||||
const tools = getPanelTools(panel);
|
||||
if (!tools) return;
|
||||
toolsHost.innerHTML = tools.innerHTML;
|
||||
};
|
||||
|
||||
const openSidebar = (persist) => {
|
||||
if (!revealSidebarOnActivate || !sidebarController) return;
|
||||
if (!sidebarController.isCollapsed()) return;
|
||||
sidebarController.open({ persist });
|
||||
};
|
||||
|
||||
const setActive = (key, { fromUser = false } = {}) => {
|
||||
if (!key) return;
|
||||
|
||||
let activePanel = null;
|
||||
panels.forEach((panel) => {
|
||||
const isActive = getPanelKey(panel) === key;
|
||||
if (isActive) activePanel = panel;
|
||||
panel.hidden = !isActive;
|
||||
panel.setAttribute('aria-hidden', isActive ? 'false' : 'true');
|
||||
});
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
const isActive = getTabKey(tab) === key;
|
||||
tab.classList.toggle('active', isActive);
|
||||
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
||||
tab.setAttribute('tabindex', isActive ? '0' : '-1');
|
||||
});
|
||||
|
||||
if (titleEl) {
|
||||
const nextTitle = activePanel?.dataset?.asideTitle || defaultTitle;
|
||||
if (nextTitle) {
|
||||
titleEl.textContent = nextTitle;
|
||||
}
|
||||
}
|
||||
if (activePanel) {
|
||||
syncTools(activePanel);
|
||||
initPanelDetails(activePanel);
|
||||
}
|
||||
writeStored(key);
|
||||
if (fromUser) {
|
||||
openSidebar(persistSidebarStateOnActivate);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialKey = () => {
|
||||
const stored = readStored();
|
||||
if (stored && panels.some((panel) => getPanelKey(panel) === stored)) {
|
||||
return stored;
|
||||
}
|
||||
const activeTab = tabs.find((tab) => tab.classList.contains('active'));
|
||||
if (activeTab) return getTabKey(activeTab);
|
||||
const visiblePanel = panels.find((panel) => !panel.hidden);
|
||||
if (visiblePanel) return getPanelKey(visiblePanel);
|
||||
return getTabKey(tabs[0]);
|
||||
};
|
||||
|
||||
setActive(getInitialKey(), { fromUser: false });
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const key = getTabKey(tab);
|
||||
const isActive = tab.classList.contains('active');
|
||||
const href = getTabHref(tab);
|
||||
if (href) {
|
||||
const base = document.baseURI || window.location.origin;
|
||||
const targetUrl = new URL(href, base);
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const isSame = targetUrl.pathname === currentUrl.pathname && targetUrl.search === currentUrl.search;
|
||||
if (!isSame) {
|
||||
setActive(key, { fromUser: true });
|
||||
window.location.href = targetUrl.toString();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (sidebarController && sidebarController.isCollapsed()) {
|
||||
sidebarController.open({ persist: persistSidebarStateOnActivate });
|
||||
setActive(key, { fromUser: true });
|
||||
return;
|
||||
}
|
||||
if (isActive && sidebarController) {
|
||||
sidebarController.close({ persist: true });
|
||||
return;
|
||||
}
|
||||
setActive(key, { fromUser: true });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initAsidePanels());
|
||||
} else {
|
||||
initAsidePanels();
|
||||
}
|
||||
66
web/js/components/app-toggle-details-aside.js
Normal file
66
web/js/components/app-toggle-details-aside.js
Normal file
@@ -0,0 +1,66 @@
|
||||
export function initDetailsAsideToggle(options = {}) {
|
||||
const {
|
||||
buttonSelector = '#toggle-main-content-aside',
|
||||
asideSelector = '#app-details-aside-section',
|
||||
containerSelector = '.app-details-container',
|
||||
storageKey = 'app-details-aside-collapsed'
|
||||
} = options;
|
||||
|
||||
const button = document.querySelector(buttonSelector);
|
||||
const aside = document.querySelector(asideSelector);
|
||||
if (!button || !aside) return;
|
||||
|
||||
const container = aside.closest(containerSelector) || document.querySelector(containerSelector);
|
||||
if (!container) return;
|
||||
|
||||
if (!button.getAttribute('aria-controls')) {
|
||||
button.setAttribute('aria-controls', aside.id || 'app-details-aside-section');
|
||||
}
|
||||
|
||||
const readStored = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStored = (collapsed) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, collapsed ? '1' : '0');
|
||||
} catch (error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const applyState = (collapsed, persist = false) => {
|
||||
container.classList.toggle('is-aside-collapsed', collapsed);
|
||||
aside.hidden = collapsed;
|
||||
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
if (persist) {
|
||||
writeStored(collapsed);
|
||||
}
|
||||
};
|
||||
|
||||
const getInitialState = () => {
|
||||
const stored = readStored();
|
||||
if (stored === '1' || stored === '0') {
|
||||
return stored === '1';
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
applyState(getInitialState(), false);
|
||||
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
const collapsed = !container.classList.contains('is-aside-collapsed');
|
||||
applyState(collapsed, true);
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initDetailsAsideToggle());
|
||||
} else {
|
||||
initDetailsAsideToggle();
|
||||
}
|
||||
95
web/js/components/app-toggle-sidebar.js
Normal file
95
web/js/components/app-toggle-sidebar.js
Normal file
@@ -0,0 +1,95 @@
|
||||
export function initSidebarToggle(options = {}) {
|
||||
const {
|
||||
buttonSelector = '#toggle-aside',
|
||||
asideSelector = '#app-sidebar',
|
||||
containerSelector = '.app-container',
|
||||
storageKey = 'app-sidebar-collapsed'
|
||||
} = options;
|
||||
|
||||
const aside = document.querySelector(asideSelector);
|
||||
if (!aside) return null;
|
||||
|
||||
const buttons = Array.from(document.querySelectorAll(buttonSelector));
|
||||
const container = document.querySelector(containerSelector);
|
||||
const root = document.documentElement;
|
||||
|
||||
const readStored = () => {
|
||||
try {
|
||||
return window.localStorage.getItem(storageKey);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const writeStored = (collapsed) => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, collapsed ? '1' : '0');
|
||||
} catch (error) {
|
||||
// ignore storage errors
|
||||
}
|
||||
};
|
||||
|
||||
const applyState = (collapsed, { persist = false } = {}) => {
|
||||
aside.classList.toggle('is-collapsed', collapsed);
|
||||
if (container) {
|
||||
container.classList.toggle('is-sidebar-collapsed', collapsed);
|
||||
}
|
||||
if (root) {
|
||||
root.classList.toggle('sidebar-collapsed', collapsed);
|
||||
}
|
||||
buttons.forEach((button) => {
|
||||
if (!button.getAttribute('aria-controls')) {
|
||||
button.setAttribute('aria-controls', aside.id || 'app-sidebar');
|
||||
}
|
||||
button.setAttribute('aria-expanded', collapsed ? 'false' : 'true');
|
||||
});
|
||||
if (persist) {
|
||||
writeStored(collapsed);
|
||||
}
|
||||
};
|
||||
|
||||
const isCollapsed = () => aside.classList.contains('is-collapsed');
|
||||
|
||||
const getInitialState = () => {
|
||||
const stored = readStored();
|
||||
if (stored === '1' || stored === '0') {
|
||||
return stored === '1';
|
||||
}
|
||||
return root?.classList.contains('sidebar-collapsed') ?? false;
|
||||
};
|
||||
|
||||
const controller = {
|
||||
isCollapsed,
|
||||
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 })
|
||||
};
|
||||
|
||||
applyState(getInitialState(), { persist: false });
|
||||
|
||||
buttons.forEach((button) => {
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
controller.toggle({ persist: true });
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('app:sidebar', (event) => {
|
||||
const detail = event.detail || {};
|
||||
const action = detail.action;
|
||||
const persist = detail.persist ?? false;
|
||||
if (action === 'open') controller.open({ persist });
|
||||
if (action === 'close') controller.close({ persist });
|
||||
if (action === 'toggle') controller.toggle({ persist });
|
||||
});
|
||||
|
||||
window.AppSidebar = controller;
|
||||
return controller;
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => initSidebarToggle());
|
||||
} else {
|
||||
initSidebarToggle();
|
||||
}
|
||||
86
web/js/components/app-toggle-toolbar.js
Normal file
86
web/js/components/app-toggle-toolbar.js
Normal file
@@ -0,0 +1,86 @@
|
||||
export function initToolbarToggles(root = document) {
|
||||
const toggles = root.querySelectorAll('[data-toolbar-toggle]');
|
||||
toggles.forEach((toggle) => {
|
||||
if (toggle.dataset.toolbarBound === '1') {
|
||||
return;
|
||||
}
|
||||
toggle.dataset.toolbarBound = '1';
|
||||
if (!toggle.hasAttribute('type')) {
|
||||
toggle.setAttribute('type', 'button');
|
||||
}
|
||||
|
||||
const targetSelector = toggle.dataset.toolbarTarget || toggle.getAttribute('aria-controls');
|
||||
if (!targetSelector) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = Array.from(root.querySelectorAll(targetSelector)).filter(Boolean);
|
||||
if (!targets.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const labelTarget = toggle.querySelector('[data-toolbar-label]');
|
||||
const showLabel = toggle.dataset.toolbarTextShow;
|
||||
const hideLabel = toggle.dataset.toolbarTextHide;
|
||||
const storageKey =
|
||||
toggle.dataset.toolbarStorageKey ||
|
||||
(targets.length === 1 && targets[0].id
|
||||
? `toolbar:${targets[0].id}`
|
||||
: `toolbar:${targetSelector}`);
|
||||
|
||||
if (!toggle.getAttribute('aria-controls') && targets.length === 1 && targets[0].id) {
|
||||
toggle.setAttribute('aria-controls', targets[0].id);
|
||||
}
|
||||
|
||||
targets.forEach((target) => {
|
||||
if (target.dataset.toolbarInitialized === '1') {
|
||||
return;
|
||||
}
|
||||
const initial = target.dataset.toolbarInitial;
|
||||
if (initial === 'hidden') {
|
||||
target.hidden = true;
|
||||
} else if (initial === 'shown') {
|
||||
target.hidden = false;
|
||||
}
|
||||
target.dataset.toolbarInitialized = '1';
|
||||
});
|
||||
|
||||
if (storageKey) {
|
||||
try {
|
||||
const stored = localStorage.getItem(storageKey);
|
||||
if (stored === 'hidden' || stored === 'shown') {
|
||||
const shouldShow = stored === 'shown';
|
||||
targets.forEach((target) => {
|
||||
target.hidden = !shouldShow;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
|
||||
const getExpanded = () => targets.every((target) => !target.hidden);
|
||||
const setExpanded = (expanded) => {
|
||||
targets.forEach((target) => {
|
||||
target.hidden = !expanded;
|
||||
});
|
||||
toggle.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
||||
if (labelTarget && (showLabel || hideLabel)) {
|
||||
labelTarget.textContent = expanded ? (hideLabel || labelTarget.textContent) : (showLabel || labelTarget.textContent);
|
||||
}
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, expanded ? 'shown' : 'hidden');
|
||||
} catch (error) {
|
||||
// localStorage not available
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setExpanded(getExpanded());
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
setExpanded(!getExpanded());
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user