This commit is contained in:
2026-02-04 23:31:53 +01:00
commit cd59ccd99b
2401 changed files with 56808 additions and 0 deletions

21
web/js/app-init.js Normal file
View File

@@ -0,0 +1,21 @@
import './components/app-flash-auto-dismiss.js';
import './components/app-fslightbox-refresh.js';
import './components/app-password-hints.js';
import './components/app-copy-badge.js';
import './components/app-multiselect-init.js';
import './components/app-nav-history.js';
import './components/app-toggle-sidebar.js';
import './components/app-toggle-aside-panels.js';
import './components/app-toggle-details-aside.js';
import './components/app-global-search.js';
import './components/app-theme-toggle.js';
import './components/app-tenant-switcher.js';
import './components/app-tabs.js';
import './components/app-color-default-toggle.js';
// Ensure fslightbox is refreshed once the vendor script is loaded.
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
} else {
window.__fsLightboxRefreshPending = true;
}

2
web/js/app-login-init.js Normal file
View File

@@ -0,0 +1,2 @@
import './components/app-flash-auto-dismiss.js';
import './components/app-password-hints.js';

View 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 = '';
}
}

View 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() };
}

View 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);
});

View 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();
}

View 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();
}

View 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();
}

View 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();
}
}

View 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();
}

View 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();
}

View 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();
}
});
}
}
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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();
}

View 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());
});
});
}

View File

@@ -0,0 +1,604 @@
export function createServerGrid(options) {
const {
container,
dataUrl,
appBase = '/',
columns,
mapData,
total = (data) => data.total,
sortColumns = [],
paginationLimit = 10,
language = {},
search = null,
filters = [],
urlSync = true,
gridjs,
rowDblClick = null,
actions = null,
rowDataset = null,
selection = null
} = options || {};
const gridjsLib = gridjs || window.gridjs;
const containerEl = typeof container === 'string' ? document.querySelector(container) : container;
if (!containerEl || !gridjsLib) {
return null;
}
const params = new URLSearchParams(window.location.search);
const resolveUrl = (path) => new URL(path, appBase).toString();
const addParam = (url, key, value) => {
const nextUrl = new URL(url);
nextUrl.searchParams.set(key, value);
return nextUrl.toString();
};
const getInput = (input) => {
if (!input) return null;
return typeof input === 'string' ? document.querySelector(input) : input;
};
const getInputValue = (input) => {
if (!input) return '';
if (input.type === 'checkbox') {
return input.checked ? (input.value || '1') : '';
}
if (input.tagName === 'SELECT' && input.multiple) {
return Array.from(input.selectedOptions || []).map((option) => option.value).filter(Boolean);
}
return (input.value ?? '').toString();
};
const setInputValue = (input, value) => {
if (!input) return;
if (input.type === 'checkbox') {
input.checked = value === true || value === 'true' || value === '1';
} else if (input.tagName === 'SELECT' && input.multiple) {
const values = Array.isArray(value)
? value.map((item) => item.toString())
: value
? value.toString().split(',').map((item) => item.trim()).filter(Boolean)
: [];
Array.from(input.options || []).forEach((option) => {
option.selected = values.includes(option.value);
});
} else {
input.value = value;
}
if (input._multiSelectInstance && typeof input._multiSelectInstance.setValues === 'function') {
const values = Array.isArray(value)
? value.map((item) => item.toString())
: value
? value.toString().split(',').map((item) => item.trim()).filter(Boolean)
: [];
input._multiSelectInstance.setValues(values);
}
};
let searchValue = '';
let searchParam = 'search';
let searchInput = null;
let searchDebounce = 250;
if (search) {
searchInput = getInput(search.input);
searchParam = search.param || searchParam;
searchDebounce = search.debounce ?? searchDebounce;
const initial = params.get(searchParam) ?? search.default ?? '';
searchValue = initial || '';
if (searchInput) {
setInputValue(searchInput, initial);
}
}
const filterState = filters.map((filter) => {
const input = getInput(filter.input);
const initial = params.get(filter.param);
const value = initial !== null ? initial : (filter.default ?? '');
if (input) {
const parsed = typeof filter.parse === 'function' ? filter.parse(value) : value;
setInputValue(input, parsed);
}
return { ...filter, input, value };
});
const normalizeValue = (filter, value) => {
if (typeof filter.normalize === 'function') {
return filter.normalize(value);
}
return value;
};
const buildParams = () => {
const query = new URLSearchParams();
if (searchValue) {
query.set(searchParam, searchValue);
}
filterState.forEach((filter) => {
const raw = getInputValue(filter.input) || filter.value || '';
const normalized = normalizeValue(filter, raw);
if (Array.isArray(normalized)) {
const list = normalized.map((item) => item.toString()).filter(Boolean);
if (list.length) {
query.set(filter.param, list.join(','));
}
return;
}
if (normalized !== '' && normalized !== null && typeof normalized !== 'undefined') {
query.set(filter.param, normalized);
}
});
return query;
};
const baseUrl = () => {
const url = new URL(dataUrl, appBase);
const query = buildParams();
query.forEach((value, key) => url.searchParams.set(key, value));
return url.toString();
};
const updateUrl = () => {
if (!urlSync) return;
const url = new URL(window.location.href);
if (searchParam) {
url.searchParams.delete(searchParam);
}
filterState.forEach((filter) => url.searchParams.delete(filter.param));
const query = buildParams();
query.forEach((value, key) => url.searchParams.set(key, value));
window.history.replaceState({}, '', url.toString());
};
const actionConfig = actions && actions.enabled ? {
label: actions.label || 'Actions',
index: Number.isInteger(actions.index) ? actions.index : null,
items: Array.isArray(actions.items) ? actions.items : [],
getRowId: typeof actions.getRowId === 'function' ? actions.getRowId : null,
wrapperClass: actions.wrapperClass || 'grid-actions',
onAction: typeof actions.onAction === 'function' ? actions.onAction : null
} : null;
const selectionConfig = selection && selection.enabled ? {
id: selection.id || 'selectRow',
index: Number.isInteger(selection.index) ? selection.index : 0,
component: selection.component || null,
props: selection.props || null,
onChange: typeof selection.onChange === 'function' ? selection.onChange : null,
getSelectedIds: typeof selection.getSelectedIds === 'function' ? selection.getSelectedIds : null,
selectAllLabel: selection.selectAllLabel || 'Select all',
selectAllEnabled: selection.selectAllEnabled !== false
} : null;
const insertActionColumn = (cols) => {
if (!actionConfig) return cols;
const actionColumn = {
name: actionConfig.label,
sort: false,
formatter: (_cell, row) => {
const id = actionConfig.getRowId ? actionConfig.getRowId(row) : null;
if (!id) {
return gridjsLib.html('');
}
const buttons = actionConfig.items.map((item) => {
if (!item || !item.key) return '';
const label = typeof item.label === 'function' ? item.label(row, id) : item.label;
if (!label) return '';
const className = item.className ? ` ${item.className}` : '';
const type = item.type || 'button';
if (type === 'link') {
const href = typeof item.href === 'function' ? item.href({ row, id }) : item.href;
const url = href || '#';
return `<a role="button" class="${className.trim()}" data-grid-action="${item.key}" href="${url}">${label}</a>`;
}
return `<button type="button" class="${className.trim()}" data-grid-action="${item.key}">${label}</button>`;
}).join('');
return gridjsLib.html(`<div class="${actionConfig.wrapperClass}" role="group">${buttons}</div>`);
}
};
const next = [...cols];
const index = actionConfig.index === null ? next.length : Math.min(Math.max(actionConfig.index, 0), next.length);
next.splice(index, 0, actionColumn);
return next;
};
const insertSelectionColumn = (cols) => {
if (!selectionConfig) return cols;
const headerLabel = selectionConfig.selectAllLabel || 'Select all';
const header = selectionConfig.selectAllEnabled
? gridjsLib.html(`<input type="checkbox" data-grid-select-all aria-label="${headerLabel}">`)
: '';
const selectionColumn = {
id: selectionConfig.id,
name: header,
sort: false,
plugin: {
id: selectionConfig.id
}
};
const next = [...cols];
const index = Math.min(Math.max(selectionConfig.index ?? 0, 0), next.length);
next.splice(index, 0, selectionColumn);
return next;
};
const insertActionSort = (cols) => {
if (!actionConfig || !cols.length) return cols;
const next = [...cols];
const index = actionConfig.index === null ? next.length : Math.min(Math.max(actionConfig.index, 0), next.length);
next.splice(index, 0, null);
return next;
};
const insertSelectionSort = (cols) => {
if (!selectionConfig || !cols.length) return cols;
const next = [...cols];
const index = Math.min(Math.max(selectionConfig.index ?? 0, 0), next.length);
next.splice(index, 0, null);
return next;
};
const mapDataWithActions = (data) => {
const rows = mapData ? mapData(data) : [];
if (!actionConfig || !Array.isArray(rows)) {
return rows;
}
const index = actionConfig.index === null ? null : Math.min(Math.max(actionConfig.index, 0), Infinity);
return rows.map((row) => {
if (!Array.isArray(row)) return row;
const next = [...row];
const insertAt = index === null ? next.length : Math.min(index, next.length);
next.splice(insertAt, 0, null);
return next;
});
};
const serverConfig = {
url: baseUrl(),
then: mapDataWithActions,
total
};
let finalSortColumns = sortColumns;
if (selectionConfig) {
finalSortColumns = insertSelectionSort(finalSortColumns);
}
if (actionConfig) {
finalSortColumns = insertActionSort(finalSortColumns);
}
let currentSort = null;
const sortConfig = finalSortColumns.length ? {
enabled: true,
server: {
url: (prev, columns) => {
if (!columns.length) {
currentSort = null;
return prev;
}
const column = columns[0];
const key = finalSortColumns[column.index];
if (!key) {
currentSort = null;
return prev;
}
const direction = column.direction === 1 ? 'asc' : 'desc';
currentSort = { order: key, dir: direction };
return addParam(addParam(prev, 'order', key), 'dir', direction);
}
}
} : { enabled: false };
const paginationConfig = {
limit: paginationLimit,
server: {
url: (prev, page, limit) => {
const offset = page * limit;
return addParam(addParam(prev, 'limit', limit), 'offset', offset);
}
}
};
const plugins = [];
if (selectionConfig?.component) {
plugins.push({
id: selectionConfig.id,
component: selectionConfig.component,
props: selectionConfig.props || {}
});
}
const grid = new gridjsLib.Grid({
columns: insertActionColumn(insertSelectionColumn(columns)),
server: serverConfig,
search: false,
sort: sortConfig,
pagination: paginationConfig,
language,
plugins
});
grid.render(containerEl);
setTimeout(() => {
initSelectAll();
}, 0);
// Prevent empty-state flash before data arrives.
let hasLoadedOnce = false;
const markLoadedOnce = () => {
if (hasLoadedOnce) return;
hasLoadedOnce = true;
containerEl.classList.add('gridjs-has-loaded');
};
const setUpdating = () => containerEl.classList.add('gridjs-is-updating');
const clearUpdating = () => containerEl.classList.remove('gridjs-is-updating');
const store = grid?.config?.store;
if (store?.subscribe) {
store.subscribe((state) => {
if (!state) return;
// Grid.js status: 0=Init, 1=Loading, 2=Loaded, 3=Rendered, 4=Error
if (state.status === 1) {
setUpdating();
} else {
clearUpdating();
}
if (state.status >= 2) {
markLoadedOnce();
}
if (state.status >= 3) {
initSelectAll();
}
});
}
const applyRowDataset = () => {
if (typeof rowDataset !== 'function') return;
const rowEls = containerEl.querySelectorAll('.gridjs-tbody .gridjs-tr');
const dataRows = grid.config.store.getState().data?.rows || [];
rowEls.forEach((rowEl, index) => {
const rowData = dataRows[index] || null;
if (!rowData) return;
const attrs = rowDataset(rowData, index) || {};
Object.keys(attrs).forEach((key) => {
const value = attrs[key];
const attr = `data-${key}`;
if (value === null || typeof value === 'undefined' || value === '') {
rowEl.removeAttribute(attr);
} else {
rowEl.setAttribute(attr, String(value));
}
});
});
};
const initSelectAll = () => {
if (!selectionConfig?.component || !selectionConfig.selectAllEnabled) return;
const header = containerEl.querySelector(`th[data-column-id="${selectionConfig.id}"] input[data-grid-select-all]`);
if (!header) return;
if (header.dataset.bound === '1') {
return;
}
header.dataset.bound = '1';
const getRowCheckboxes = () => Array.from(
containerEl.querySelectorAll(`td[data-column-id="${selectionConfig.id}"] input[type="checkbox"]`)
);
const updateHeaderState = () => {
const boxes = getRowCheckboxes();
const total = boxes.length;
const checked = boxes.filter((box) => box.checked).length;
header.indeterminate = total > 0 && checked > 0 && checked < total;
header.checked = total > 0 && checked === total;
header.disabled = total === 0;
};
header.addEventListener('change', () => {
const boxes = getRowCheckboxes();
boxes.forEach((box) => {
if (box.checked !== header.checked) {
box.click();
}
});
updateHeaderState();
});
updateHeaderState();
};
if (typeof rowDataset === 'function') {
const observer = new MutationObserver(() => applyRowDataset());
observer.observe(containerEl, { childList: true, subtree: true });
applyRowDataset();
}
const getRowDataByElement = (rowEl) => {
if (!rowEl) return null;
const rows = Array.from(rowEl.parentElement?.children || []);
const rowIndex = rows.indexOf(rowEl);
if (rowIndex < 0) return null;
const dataRows = grid.config.store.getState().data?.rows || [];
const rowData = dataRows[rowIndex] || null;
if (!rowData) return null;
return { rowData, rowIndex };
};
if (rowDblClick) {
const excludeSelector = rowDblClick.exclude || '.grid-actions, button, a, input, select, textarea';
containerEl.addEventListener('dblclick', (event) => {
if (excludeSelector && event.target.closest(excludeSelector)) {
return;
}
const rowEl = event.target.closest('.gridjs-tr');
const rowInfo = getRowDataByElement(rowEl);
if (!rowInfo) {
return;
}
const { rowData, rowIndex } = rowInfo;
const getUrl = rowDblClick.getUrl;
if (typeof getUrl !== 'function') {
return;
}
const url = getUrl(rowData, rowIndex);
if (url) {
window.location.href = url;
}
});
}
if (actionConfig) {
containerEl.addEventListener('click', async (event) => {
const actionEl = event.target.closest('[data-grid-action]');
if (!actionEl) {
return;
}
const rowEl = actionEl.closest('.gridjs-tr');
const rowInfo = getRowDataByElement(rowEl);
if (!rowInfo) {
return;
}
const { rowData } = rowInfo;
const actionKey = actionEl.getAttribute('data-grid-action') || '';
const actionItem = actionConfig.items.find((item) => item && item.key === actionKey);
if (!actionItem) {
return;
}
const id = actionConfig.getRowId ? actionConfig.getRowId(rowData) : null;
if (!id) {
return;
}
const confirmText = typeof actionItem.confirm === 'function'
? actionItem.confirm({ row: rowData, id })
: actionItem.confirm;
if (confirmText && !window.confirm(confirmText)) {
return;
}
if (actionItem.type === 'link') {
const href = typeof actionItem.href === 'function'
? actionItem.href({ row: rowData, id })
: actionItem.href;
if (href) {
event.preventDefault();
window.location.href = href;
}
return;
}
if (actionConfig.onAction) {
event.preventDefault();
await actionConfig.onAction({ action: actionKey, id, row: rowData, event, grid, container: containerEl });
}
});
}
const updateGrid = () => {
updateUrl();
grid.updateConfig({
server: { ...serverConfig, url: baseUrl() }
}).forceRender();
};
const debounce = (fn, delay = 250) => {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
};
};
if (searchInput) {
searchInput.addEventListener('input', debounce((event) => {
searchValue = event.target.value.trim();
updateGrid();
}, searchDebounce));
}
filterState.forEach((filter) => {
if (!filter.input) return;
const eventName = filter.event || 'change';
filter.input.addEventListener(eventName, () => {
filter.value = getInputValue(filter.input);
updateGrid();
});
});
const resetFilters = () => {
if (search) {
const defaultValue = search.default ?? '';
searchValue = defaultValue;
if (searchInput) {
setInputValue(searchInput, defaultValue);
}
}
filterState.forEach((filter) => {
const defaultValue = filter.default ?? '';
filter.value = defaultValue;
if (filter.input) {
setInputValue(filter.input, defaultValue);
}
});
updateGrid();
};
const getSortParams = () => (currentSort ? { ...currentSort } : null);
const selectionApi = selectionConfig && selectionConfig.component ? {
getSelectedIds: () => {
if (selectionConfig.getSelectedIds) {
return selectionConfig.getSelectedIds({ grid, container: containerEl });
}
const plugin = grid?.config?.plugin?.get?.(selectionConfig.id);
const store = plugin?.props?.store;
const state = store?.state ?? store?.getState?.();
if (!state) return [];
if (Array.isArray(state.selected)) return state.selected;
if (Array.isArray(state.ids)) return state.ids;
if (state.rows && typeof state.rows === 'object') {
return Object.keys(state.rows).filter((key) => state.rows[key]);
}
if (state instanceof Set) return Array.from(state);
if (Array.isArray(state)) return state;
if (typeof state === 'object') {
return Object.keys(state).filter((key) => state[key]);
}
return [];
},
clear: () => {
const plugin = grid?.config?.plugin?.get?.(selectionConfig.id);
const store = plugin?.props?.store;
if (store?.dispatch) {
store.dispatch('reset');
} else if (store?.reset) {
store.reset();
}
}
} : null;
if (selectionConfig?.onChange && selectionConfig.component) {
grid.on('ready', () => {
const plugin = grid?.config?.plugin?.get?.(selectionConfig.id);
const store = plugin?.props?.store;
const sync = () => {
selectionConfig.onChange(selectionApi?.getSelectedIds?.() || []);
};
if (store?.on) store.on('updated', sync);
if (store?.subscribe) store.subscribe(sync);
sync();
});
}
grid.on('ready', () => {
initSelectAll();
setTimeout(() => {
if (typeof window.requestFsLightboxRefresh === 'function') {
window.requestFsLightboxRefresh();
return;
}
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
} else {
window.__fsLightboxRefreshPending = true;
}
}, 0);
});
return { grid, container: containerEl, updateGrid, updateUrl, baseUrl, resolveUrl, getSortParams, resetFilters, selection: selectionApi };
}

View File

@@ -0,0 +1,19 @@
export const buildUrl = (appBase, path) => new URL(path, appBase).toString();
export const postAction = async (url, csrf, data = {}) => {
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch'
},
body
});
};
export const badgeHtml = (gridjs, value = '') => {
const safe = value ?? '';
return gridjs.html(`<span class="badge" data-variant="neutral">${safe}</span>`);
};

View File

@@ -0,0 +1,127 @@
export function initUsersListPage(options = {}) {
const {
gridConfig,
appBase,
csrf,
labels = {},
selectors = {},
exportPath = 'admin/users/export',
listPath = 'admin/users',
buildBulkUrl,
showFlash,
withLoading
} = options;
if (!gridConfig) return;
const exportSelector = selectors.exportButton || '[data-users-export]';
const resetSelector = selectors.resetButton || '[data-users-reset]';
const bulkSelector = selectors.bulkButtons || '[data-users-bulk]';
const resolveBulkUrl = buildBulkUrl
? buildBulkUrl
: (action) => new URL(`admin/users/bulk/${action}`, appBase).toString();
const postAction = async (url, data = {}) => {
const body = new URLSearchParams({ ...data, [csrf.key]: csrf.token });
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
'X-Requested-With': 'fetch'
},
body
});
};
const exportButton = document.querySelector(exportSelector);
if (exportButton) {
exportButton.addEventListener('click', () => {
const exportUrl = new URL(exportPath, appBase);
const dataUrl = new URL(gridConfig.baseUrl());
dataUrl.searchParams.forEach((value, key) => exportUrl.searchParams.set(key, value));
const sortParams = gridConfig.getSortParams ? gridConfig.getSortParams() : null;
if (sortParams && sortParams.order) {
exportUrl.searchParams.set('order', sortParams.order);
exportUrl.searchParams.set('dir', sortParams.dir || 'asc');
}
window.location.href = exportUrl.toString();
});
}
const resetButton = document.querySelector(resetSelector);
if (resetButton) {
resetButton.addEventListener('click', () => {
if (typeof gridConfig.resetFilters === 'function') {
gridConfig.resetFilters();
} else {
gridConfig.updateGrid();
}
});
}
const bulkButtons = Array.from(document.querySelectorAll(bulkSelector));
if (bulkButtons.length) {
bulkButtons.forEach((button) => {
button.addEventListener('click', async () => {
const action = button.getAttribute('data-users-bulk') || '';
const ids = gridConfig.selection?.getSelectedIds?.() || [];
const validActions = ['activate', 'deactivate', 'delete', 'send-access'];
if (!ids.length || !validActions.includes(action)) {
return;
}
const confirmTexts = {
'activate': labels.bulkActivateConfirm,
'deactivate': labels.bulkDeactivateConfirm,
'delete': labels.bulkDeleteConfirm,
'send-access': labels.bulkSendAccessConfirm
};
const confirmText = confirmTexts[action];
if (confirmText && !window.confirm(confirmText)) {
return;
}
const executeBulk = async () => {
const url = resolveBulkUrl(action);
const response = await postAction(url, { uuids: ids.join(',') });
const data = await response.json().catch(() => null);
if (response.ok && data && data.ok) {
gridConfig.selection?.clear?.();
gridConfig.grid?.forceRender();
if (showFlash && labels.bulkMessages) {
const msg = labels.bulkMessages[action];
if (msg) {
const count = data.count ?? 0;
const failed = data.failed ?? 0;
let text = msg.success.replace('%d', count);
if (failed > 0 && msg.partial) {
text = msg.partial.replace('%d', count).replace('%f', failed);
}
showFlash('success', text);
}
}
} else {
if (showFlash && labels.bulkMessages) {
const msg = labels.bulkMessages[action];
if (msg?.error) {
showFlash('error', msg.error);
} else {
window.location.href = new URL(listPath, appBase).toString();
}
} else {
window.location.href = new URL(listPath, appBase).toString();
}
}
};
if (withLoading) {
await withLoading(executeBulk);
} else {
await executeBulk();
}
});
});
}
}