456 lines
16 KiB
JavaScript
Executable File
456 lines
16 KiB
JavaScript
Executable File
document.documentElement.classList.add('js');
|
|
|
|
function readThemePreference(key, fallback) {
|
|
try {
|
|
return localStorage.getItem(key) || fallback;
|
|
} catch (error) {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
const moduleName = document.documentElement.dataset.module || '';
|
|
const mainThemeMode = readThemePreference('nexus.theme', 'day');
|
|
const mainThemeAccent = readThemePreference('nexus.accent', 'logo');
|
|
const moduleThemeMode = moduleName ? readThemePreference(`nexus.module.${moduleName}.theme`, 'inherit') : mainThemeMode;
|
|
const moduleThemeAccent = moduleName ? readThemePreference(`nexus.module.${moduleName}.accent`, 'inherit') : mainThemeAccent;
|
|
|
|
function normalizeThemeValue(value, allowed, fallback) {
|
|
return allowed.includes(value) ? value : fallback;
|
|
}
|
|
|
|
function storedTheme() {
|
|
const rawMode = moduleName ? readThemePreference(`nexus.module.${moduleName}.theme`, 'inherit') : readThemePreference('nexus.theme', 'day');
|
|
const rawAccent = moduleName ? readThemePreference(`nexus.module.${moduleName}.accent`, 'inherit') : readThemePreference('nexus.accent', 'logo');
|
|
const mode = moduleName ? normalizeThemeValue(rawMode, ['inherit', 'day', 'night'], 'inherit') : normalizeThemeValue(rawMode, ['day', 'night'], 'day');
|
|
const accent = moduleName ? normalizeThemeValue(rawAccent, ['inherit', 'logo', 'pink', 'cyan', 'orange', 'green'], 'inherit') : normalizeThemeValue(rawAccent, ['logo', 'pink', 'cyan', 'orange', 'green'], 'logo');
|
|
return { mode, accent };
|
|
}
|
|
|
|
function applyTheme(mode, accent, persist = true) {
|
|
const allowedModes = moduleName ? ['inherit', 'day', 'night'] : ['day', 'night'];
|
|
const allowedAccents = moduleName ? ['inherit', 'logo', 'pink', 'cyan', 'orange', 'green'] : ['logo', 'pink', 'cyan', 'orange', 'green'];
|
|
const normalizedMode = normalizeThemeValue(mode, allowedModes, moduleName ? 'inherit' : 'day');
|
|
const normalizedAccent = normalizeThemeValue(accent, allowedAccents, moduleName ? 'inherit' : 'logo');
|
|
const effectiveMode = normalizedMode === 'inherit' ? mainThemeMode : normalizedMode;
|
|
const effectiveAccent = normalizedAccent === 'inherit' ? mainThemeAccent : normalizedAccent;
|
|
document.documentElement.dataset.theme = effectiveMode;
|
|
document.documentElement.dataset.accent = effectiveAccent;
|
|
try {
|
|
if (persist) {
|
|
if (moduleName) {
|
|
if (normalizedMode === 'inherit') {
|
|
localStorage.removeItem(`nexus.module.${moduleName}.theme`);
|
|
} else {
|
|
localStorage.setItem(`nexus.module.${moduleName}.theme`, normalizedMode);
|
|
}
|
|
if (normalizedAccent === 'inherit') {
|
|
localStorage.removeItem(`nexus.module.${moduleName}.accent`);
|
|
} else {
|
|
localStorage.setItem(`nexus.module.${moduleName}.accent`, normalizedAccent);
|
|
}
|
|
} else {
|
|
localStorage.setItem('nexus.theme', normalizedMode);
|
|
localStorage.setItem('nexus.accent', normalizedAccent);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Ignore blocked storage; the current page still receives the theme.
|
|
}
|
|
return { mode: normalizedMode, accent: normalizedAccent };
|
|
}
|
|
|
|
const initialTheme = applyTheme(moduleThemeMode, moduleThemeAccent, false);
|
|
|
|
const themeModeSelect = document.querySelector('[data-theme-mode]');
|
|
const themeAccentSelect = document.querySelector('[data-theme-accent]');
|
|
|
|
if (themeModeSelect) {
|
|
themeModeSelect.value = initialTheme.mode;
|
|
themeModeSelect.addEventListener('change', () => {
|
|
const current = storedTheme();
|
|
applyTheme(themeModeSelect.value, current.accent);
|
|
});
|
|
}
|
|
|
|
if (themeAccentSelect) {
|
|
themeAccentSelect.value = initialTheme.accent;
|
|
themeAccentSelect.addEventListener('change', () => {
|
|
const current = storedTheme();
|
|
applyTheme(current.mode, themeAccentSelect.value);
|
|
});
|
|
}
|
|
|
|
for (const element of document.querySelectorAll('[data-reveal]')) {
|
|
element.classList.add('reveal');
|
|
}
|
|
|
|
const setupTabs = document.querySelectorAll('[data-setup-tab-target]');
|
|
if (setupTabs.length > 0) {
|
|
const setupPanels = document.querySelectorAll('.setup-db-panel');
|
|
const setupControls = document.querySelectorAll('.setup-db-panel input, .setup-db-panel select, .setup-db-panel textarea');
|
|
for (const control of setupControls) {
|
|
if (control.required) {
|
|
control.dataset.setupRequired = 'true';
|
|
}
|
|
}
|
|
|
|
const activateSetupTab = (targetId) => {
|
|
const dbPanelsRoot = document.querySelector('[data-setup-db-panels]');
|
|
const dbPanelsHidden = dbPanelsRoot ? dbPanelsRoot.hidden : false;
|
|
for (const tab of setupTabs) {
|
|
const isActive = tab.dataset.setupTabTarget === targetId;
|
|
tab.classList.toggle('is-active', isActive);
|
|
tab.setAttribute('aria-selected', isActive ? 'true' : 'false');
|
|
}
|
|
for (const panel of setupPanels) {
|
|
const isActive = !dbPanelsHidden && panel.id === targetId;
|
|
panel.hidden = !isActive;
|
|
for (const control of panel.querySelectorAll('input, select, textarea')) {
|
|
if (control.dataset.setupRequired === 'true') {
|
|
control.required = isActive;
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
for (const tab of setupTabs) {
|
|
tab.addEventListener('click', () => {
|
|
activateSetupTab(tab.dataset.setupTabTarget);
|
|
});
|
|
}
|
|
|
|
const activeSetupTab = document.querySelector('[data-setup-tab-target].is-active') || setupTabs[0];
|
|
activateSetupTab(activeSetupTab.dataset.setupTabTarget);
|
|
}
|
|
|
|
window.NexusModal = (() => {
|
|
const openModals = new Set();
|
|
|
|
const syncBodyState = () => {
|
|
document.body.classList.toggle('has-modal-open', openModals.size > 0);
|
|
};
|
|
|
|
const create = (root, options = {}) => {
|
|
if (!root) {
|
|
return null;
|
|
}
|
|
|
|
const modal = {
|
|
root,
|
|
isOpen: () => root.classList.contains('is-open'),
|
|
open() {
|
|
root.classList.add('is-open');
|
|
root.setAttribute('aria-hidden', 'false');
|
|
openModals.add(root);
|
|
syncBodyState();
|
|
if (typeof options.onOpen === 'function') {
|
|
options.onOpen();
|
|
}
|
|
const focusTarget = options.initialFocus ? root.querySelector(options.initialFocus) : null;
|
|
if (focusTarget instanceof HTMLElement) {
|
|
window.setTimeout(() => focusTarget.focus(), 20);
|
|
}
|
|
},
|
|
close() {
|
|
root.classList.remove('is-open');
|
|
root.setAttribute('aria-hidden', 'true');
|
|
openModals.delete(root);
|
|
syncBodyState();
|
|
if (typeof options.onClose === 'function') {
|
|
options.onClose();
|
|
}
|
|
},
|
|
};
|
|
|
|
if (!root.dataset.modalBound) {
|
|
root.addEventListener('click', (event) => {
|
|
if (event.target === root) {
|
|
modal.close();
|
|
}
|
|
});
|
|
root.dataset.modalBound = '1';
|
|
}
|
|
|
|
return modal;
|
|
};
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Escape') {
|
|
return;
|
|
}
|
|
const active = Array.from(openModals).pop();
|
|
if (!active) {
|
|
return;
|
|
}
|
|
active.classList.remove('is-open');
|
|
active.setAttribute('aria-hidden', 'true');
|
|
openModals.delete(active);
|
|
syncBodyState();
|
|
});
|
|
|
|
return { create };
|
|
})();
|
|
|
|
(() => {
|
|
const root = document.getElementById('nexus-debug-root');
|
|
const dataNode = document.getElementById('nexus-debug-data');
|
|
if (!root || !dataNode) {
|
|
return;
|
|
}
|
|
|
|
let payload = { enabled: false, entries: [] };
|
|
try {
|
|
const parsed = JSON.parse(dataNode.textContent || '{}');
|
|
if (parsed && typeof parsed === 'object') {
|
|
payload = parsed;
|
|
}
|
|
} catch (error) {
|
|
payload = { enabled: false, entries: [] };
|
|
}
|
|
|
|
const enabled = !!payload.enabled && document.body.dataset.nexusDebugEnabled === '1';
|
|
const isAdmin = document.body.dataset.nexusDebugAdmin === '1';
|
|
const entries = Array.isArray(payload.entries) ? payload.entries.slice(0, 250) : [];
|
|
let sequence = entries.length;
|
|
|
|
const debugBus = window.__nexusDebugBus || { enabled: false, listener: null };
|
|
debugBus.enabled = enabled;
|
|
window.__nexusDebugBus = debugBus;
|
|
|
|
if (!isAdmin || !enabled) {
|
|
return;
|
|
}
|
|
|
|
const bugSvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4.5h6l1.2 2.2 2.5-1.4 1 1.7-2.4 1.4A6.9 6.9 0 0 1 19 13h2.5v2H19a6.9 6.9 0 0 1-.7 2.9l2.4 1.4-1 1.7-2.5-1.4L15 21.5H9l-1.2-2.2-2.5 1.4-1-1.7 2.4-1.4A6.9 6.9 0 0 1 6 15H3.5v-2H6a6.9 6.9 0 0 1 .7-2.9L4.3 8.7l1-1.7 2.5 1.4L9 4.5Zm1.2 2L9 8.8V15a3 3 0 0 0 6 0V8.8l-1.2-2.3h-3.6ZM10.5 11a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm3 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Z" fill="currentColor"/></svg>';
|
|
const copySvg = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 9.75A2.25 2.25 0 0 1 11.25 7.5h7.5A2.25 2.25 0 0 1 21 9.75v8.25a2.25 2.25 0 0 1-2.25 2.25h-7.5A2.25 2.25 0 0 1 9 18V9.75Zm-6 4.5V6A2.25 2.25 0 0 1 5.25 3.75h7.5A2.25 2.25 0 0 1 15 6v.75h-3.75A3.75 3.75 0 0 0 7.5 10.5v7.5h-2.25A2.25 2.25 0 0 1 3 15.75v-1.5Z" fill="currentColor"/></svg>';
|
|
|
|
root.innerHTML = `
|
|
<button type="button" class="nexus-debug-bug" aria-label="Debug öffnen">
|
|
${bugSvg}
|
|
<span class="nexus-debug-badge">0</span>
|
|
</button>
|
|
<section class="nexus-debug-popup" aria-hidden="true">
|
|
<div class="nexus-debug-popup__head">
|
|
<div>
|
|
<div class="nexus-debug-popup__eyebrow">Nexus Debug</div>
|
|
<h2>Nexus Debug</h2>
|
|
</div>
|
|
<button type="button" class="module-button module-button--secondary module-button--small" data-debug-close>Schließen</button>
|
|
</div>
|
|
<div class="nexus-debug-popup__toolbar">
|
|
<div class="muted">Projektweiter Debug-Stream der aktuellen Admin-Sitzung.</div>
|
|
<div class="nexus-debug-popup__actions">
|
|
<button type="button" class="module-button module-button--secondary module-button--small" data-debug-reload>Neu laden</button>
|
|
<button type="button" class="module-button module-button--secondary module-button--small" data-debug-copy>Kopieren</button>
|
|
<button type="button" class="module-button module-button--secondary module-button--small" data-debug-clear>Leeren</button>
|
|
</div>
|
|
</div>
|
|
<div class="nexus-debug-popup__body" data-debug-list></div>
|
|
</section>
|
|
`;
|
|
|
|
const bugButton = root.querySelector('.nexus-debug-bug');
|
|
const badge = root.querySelector('.nexus-debug-badge');
|
|
const popup = root.querySelector('.nexus-debug-popup');
|
|
const closeButton = root.querySelector('[data-debug-close]');
|
|
const reloadButton = root.querySelector('[data-debug-reload]');
|
|
const copyButton = root.querySelector('[data-debug-copy]');
|
|
const clearButton = root.querySelector('[data-debug-clear]');
|
|
const list = root.querySelector('[data-debug-list]');
|
|
|
|
const updateBadge = () => {
|
|
if (!badge) {
|
|
return;
|
|
}
|
|
badge.textContent = String(entries.length);
|
|
badge.hidden = entries.length === 0;
|
|
};
|
|
|
|
const normalizeEntry = (entry) => {
|
|
sequence += 1;
|
|
return {
|
|
id: entry && entry.id ? entry.id : `nexus-debug-${sequence}`,
|
|
source: entry && entry.source ? String(entry.source) : 'nexus',
|
|
type: entry && entry.type ? String(entry.type) : 'debug',
|
|
label: entry && entry.label ? String(entry.label) : '',
|
|
at: entry && (entry.at || entry.time) ? String(entry.at || entry.time) : new Date().toISOString(),
|
|
payload: entry && typeof entry === 'object' ? entry : { value: entry },
|
|
};
|
|
};
|
|
|
|
const renderEntries = () => {
|
|
if (!list) {
|
|
return;
|
|
}
|
|
updateBadge();
|
|
if (!entries.length) {
|
|
list.innerHTML = '<div class="nexus-debug-empty">Noch keine Debug-Einträge vorhanden.</div>';
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = entries.map((entry, index) => {
|
|
const title = entry.label || entry.type || `Eintrag ${index + 1}`;
|
|
const payloadText = JSON.stringify(entry.payload, null, 2);
|
|
return `
|
|
<details class="nexus-debug-entry"${index === 0 ? ' open' : ''}>
|
|
<summary>
|
|
<span class="nexus-debug-entry__title">
|
|
<strong>${escapeHtml(title)}</strong>
|
|
<span class="nexus-debug-entry__meta">${escapeHtml(entry.source)} · ${escapeHtml(entry.at)}</span>
|
|
</span>
|
|
<button type="button" class="module-button module-button--secondary module-button--small nexus-debug-entry__copy" data-entry-copy="${escapeHtml(entry.id)}" aria-label="Eintrag kopieren" title="Eintrag kopieren">${copySvg}</button>
|
|
</summary>
|
|
<pre>${escapeHtml(payloadText)}</pre>
|
|
</details>
|
|
`;
|
|
}).join('');
|
|
};
|
|
|
|
const escapeHtml = (value) => String(value ?? '')
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
|
|
const copyText = async (text, successLabel, errorLabel) => {
|
|
try {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
await navigator.clipboard.writeText(text);
|
|
} else {
|
|
const textarea = document.createElement('textarea');
|
|
textarea.value = text;
|
|
textarea.setAttribute('readonly', 'readonly');
|
|
textarea.style.position = 'absolute';
|
|
textarea.style.left = '-9999px';
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(textarea);
|
|
}
|
|
appendEntry({
|
|
source: 'nexus',
|
|
type: 'debug.copy.success',
|
|
label: successLabel,
|
|
});
|
|
} catch (error) {
|
|
appendEntry({
|
|
source: 'nexus',
|
|
type: 'debug.copy.error',
|
|
label: errorLabel,
|
|
message: error && error.message ? error.message : 'Kopieren fehlgeschlagen.',
|
|
});
|
|
}
|
|
};
|
|
|
|
const openPopup = () => {
|
|
popup?.classList.add('is-open');
|
|
popup?.setAttribute('aria-hidden', 'false');
|
|
};
|
|
|
|
const closePopup = () => {
|
|
popup?.classList.remove('is-open');
|
|
popup?.setAttribute('aria-hidden', 'true');
|
|
};
|
|
|
|
const appendEntry = (entry) => {
|
|
entries.unshift(normalizeEntry(entry));
|
|
if (entries.length > 250) {
|
|
entries.length = 250;
|
|
}
|
|
renderEntries();
|
|
};
|
|
|
|
const reloadEntries = async () => {
|
|
try {
|
|
const response = await fetch('/api/debug/entries', {
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
const nextEntries = Array.isArray(data?.data?.entries) ? data.data.entries.map(normalizeEntry) : [];
|
|
entries.splice(0, entries.length, ...nextEntries);
|
|
renderEntries();
|
|
} catch (error) {
|
|
appendEntry({
|
|
source: 'nexus',
|
|
type: 'debug.reload.error',
|
|
label: 'Reload fehlgeschlagen',
|
|
message: error && error.message ? error.message : 'Debug-Einträge konnten nicht geladen werden.',
|
|
});
|
|
}
|
|
};
|
|
|
|
const clearEntries = async () => {
|
|
try {
|
|
await fetch('/api/debug/clear', {
|
|
method: 'POST',
|
|
credentials: 'same-origin',
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
} catch (error) {
|
|
// Ignore clear failures; local UI is still reset below.
|
|
}
|
|
entries.splice(0, entries.length);
|
|
renderEntries();
|
|
};
|
|
|
|
const copyAllEntries = async () => {
|
|
const content = entries
|
|
.slice()
|
|
.reverse()
|
|
.map((entry) => JSON.stringify(entry.payload, null, 2))
|
|
.join('\n\n');
|
|
if (!content) {
|
|
appendEntry({
|
|
source: 'nexus',
|
|
type: 'debug.copy.empty',
|
|
label: 'Keine Debug-Einträge zum Kopieren',
|
|
});
|
|
return;
|
|
}
|
|
await copyText(content, 'Debug-Einträge kopiert', 'Debug-Einträge konnten nicht kopiert werden');
|
|
};
|
|
|
|
const previousListener = typeof debugBus.listener === 'function' ? debugBus.listener : null;
|
|
debugBus.listener = (entry) => {
|
|
if (previousListener) {
|
|
previousListener(entry);
|
|
}
|
|
appendEntry(entry);
|
|
};
|
|
|
|
bugButton?.addEventListener('click', () => {
|
|
if (popup?.classList.contains('is-open')) {
|
|
closePopup();
|
|
} else {
|
|
openPopup();
|
|
}
|
|
});
|
|
closeButton?.addEventListener('click', closePopup);
|
|
reloadButton?.addEventListener('click', reloadEntries);
|
|
copyButton?.addEventListener('click', copyAllEntries);
|
|
clearButton?.addEventListener('click', clearEntries);
|
|
list?.addEventListener('click', (event) => {
|
|
const button = event.target instanceof HTMLElement ? event.target.closest('[data-entry-copy]') : null;
|
|
if (!button) {
|
|
return;
|
|
}
|
|
const entryId = button.getAttribute('data-entry-copy') || '';
|
|
const entry = entries.find((item) => String(item.id) === entryId);
|
|
if (!entry) {
|
|
return;
|
|
}
|
|
const content = JSON.stringify(entry.payload, null, 2);
|
|
copyText(content, `Eintrag ${entry.type || entry.id} kopiert`, `Eintrag ${entry.type || entry.id} konnte nicht kopiert werden`);
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key === 'Escape') {
|
|
closePopup();
|
|
}
|
|
});
|
|
|
|
renderEntries();
|
|
})();
|