feat: globale Suche und Navigationsverlauf ergänzen

This commit is contained in:
Masterluke77
2026-07-13 09:37:07 +02:00
parent 05002e0735
commit 0eaf43575e
+272
View File
@@ -0,0 +1,272 @@
(() => {
'use strict';
if (document.documentElement.dataset.uxGlobalNavigationReady === '1') return;
document.documentElement.dataset.uxGlobalNavigationReady = '1';
const VIEW_LABELS = {
dashboard: ['Übersicht', 'Dashboard', 'Start'],
today: ['Aufgaben', 'Heute', 'Tagesarbeitsplatz'],
generator: ['Neuer Artikel', 'Artikel erstellen', 'Generator'],
history: ['Artikel', 'Listings', 'Artikelübersicht'],
'quality-center': ['Qualität', 'Qualitätscenter', 'Prüfen'],
publish: ['Veröffentlichen', 'Publish', 'Marktplätze'],
inventory: ['Lager', 'Bestand', 'Lagerorte'],
templates: ['Vorlagen', 'Templates'],
'media-library': ['Medien', 'Bilder', 'Galerie'],
'image-factory': ['Bildwerkstatt', 'Bildproduktion'],
'flux-studio': ['FLUX Studio', 'ComfyUI', 'AI Bilder'],
extensions: ['Erweiterungen', 'Extensions', 'Browser Erweiterung'],
settings: ['Einstellungen', 'Verbindungen', 'APIs'],
admin: ['Verwaltung', 'Benutzer', 'Admin'],
fees: ['Gebühren', 'Kosten'],
trash: ['Papierkorb', 'Gelöscht'],
};
const state = {
activeView: null,
restoring: false,
query: '',
selectedIndex: 0,
};
const el = (tag, className, text) => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
};
function getActiveView() {
return document.querySelector('.sidebar-nav .nav-item.active[data-tab]')?.dataset.tab
|| document.querySelector('.tab-content.active')?.id
|| 'dashboard';
}
function getNavItem(view) {
return document.querySelector(`.sidebar-nav .nav-item[data-tab="${CSS.escape(view)}"]`);
}
function activateView(view, { replace = false } = {}) {
const item = getNavItem(view);
if (!item || item.classList.contains('hidden')) return false;
state.restoring = true;
item.closest('details.ux-nav-section')?.setAttribute('open', '');
item.click();
requestAnimationFrame(() => {
state.activeView = getActiveView();
const url = new URL(window.location.href);
url.hash = `view=${encodeURIComponent(state.activeView)}`;
const method = replace ? 'replaceState' : 'pushState';
history[method]({ vendooView: state.activeView }, '', url);
state.restoring = false;
updateHistoryButtons();
});
return true;
}
function ensureInitialHistoryState() {
const hashView = new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
const initial = hashView && getNavItem(hashView) ? hashView : getActiveView();
const url = new URL(window.location.href);
url.hash = `view=${encodeURIComponent(initial)}`;
history.replaceState({ vendooView: initial }, '', url);
state.activeView = initial;
if (initial !== getActiveView()) activateView(initial, { replace: true });
}
function updateHistoryButtons() {
const back = document.getElementById('ux-nav-back');
const forward = document.getElementById('ux-nav-forward');
if (back) back.disabled = history.length <= 1;
if (forward) forward.disabled = false;
}
function createHistoryControls() {
const context = document.querySelector('.topbar-context');
if (!context || document.getElementById('ux-nav-history')) return;
const controls = el('div', 'ux-nav-history');
controls.id = 'ux-nav-history';
controls.setAttribute('aria-label', 'Navigationsverlauf');
const back = el('button', 'ux-nav-history-button');
back.id = 'ux-nav-back';
back.type = 'button';
back.title = 'Zurück (Alt + Pfeil links)';
back.setAttribute('aria-label', 'Zurück');
back.textContent = '←';
const forward = el('button', 'ux-nav-history-button');
forward.id = 'ux-nav-forward';
forward.type = 'button';
forward.title = 'Vorwärts (Alt + Pfeil rechts)';
forward.setAttribute('aria-label', 'Vorwärts');
forward.textContent = '→';
back.addEventListener('click', () => history.back());
forward.addEventListener('click', () => history.forward());
controls.append(back, forward);
context.querySelector('.topbar-heading')?.before(controls);
updateHistoryButtons();
}
function collectCommands() {
return Object.entries(VIEW_LABELS).map(([view, labels]) => ({
id: `view:${view}`,
view,
title: labels[0],
keywords: labels.join(' ').toLocaleLowerCase('de-DE'),
group: view === 'settings' || view === 'admin' || view === 'extensions' ? 'System' : 'Navigation',
})).filter(command => {
const item = getNavItem(command.view);
return item && !item.classList.contains('hidden');
});
}
function createDialog() {
if (document.getElementById('ux-global-search')) return document.getElementById('ux-global-search');
const dialog = el('div', 'ux-global-search hidden');
dialog.id = 'ux-global-search';
dialog.setAttribute('role', 'dialog');
dialog.setAttribute('aria-modal', 'true');
dialog.setAttribute('aria-labelledby', 'ux-global-search-title');
const panel = el('section', 'ux-global-search-panel');
const header = el('header', 'ux-global-search-header');
const title = el('h2', '', 'Suchen und öffnen');
title.id = 'ux-global-search-title';
const close = el('button', 'ux-global-search-close', '×');
close.type = 'button';
close.setAttribute('aria-label', 'Suche schließen');
const inputWrap = el('div', 'ux-global-search-input-wrap');
const input = el('input', 'ux-global-search-input');
input.id = 'ux-global-search-input';
input.type = 'search';
input.placeholder = 'Seite, Bereich oder Einstellung suchen …';
input.autocomplete = 'off';
input.setAttribute('aria-controls', 'ux-global-search-results');
const shortcut = el('kbd', '', 'Esc');
inputWrap.append(input, shortcut);
const results = el('div', 'ux-global-search-results');
results.id = 'ux-global-search-results';
results.setAttribute('role', 'listbox');
const footer = el('footer', 'ux-global-search-footer');
footer.textContent = '↑↓ auswählen · Enter öffnen · Esc schließen';
header.append(title, close);
panel.append(header, inputWrap, results, footer);
dialog.append(panel);
document.body.append(dialog);
const closeDialog = () => {
dialog.classList.add('hidden');
document.body.classList.remove('ux-search-open');
state.query = '';
state.selectedIndex = 0;
input.value = '';
};
const executeSelected = () => {
const selected = results.querySelector('[aria-selected="true"]');
const view = selected?.dataset.view;
if (!view) return;
closeDialog();
activateView(view);
};
const render = () => {
const query = input.value.trim().toLocaleLowerCase('de-DE');
state.query = query;
const commands = collectCommands().filter(command => !query || `${command.title} ${command.keywords} ${command.group}`.toLocaleLowerCase('de-DE').includes(query));
state.selectedIndex = Math.min(state.selectedIndex, Math.max(0, commands.length - 1));
results.replaceChildren();
if (!commands.length) {
const empty = el('div', 'ux-global-search-empty');
empty.append(el('strong', '', 'Keine passende Seite gefunden'), el('span', '', 'Versuche einen allgemeineren Begriff. Produktive Aktionen werden hier bewusst nicht automatisch ausgeführt.'));
results.append(empty);
return;
}
commands.forEach((command, index) => {
const button = el('button', 'ux-global-search-result');
button.type = 'button';
button.dataset.view = command.view;
button.setAttribute('role', 'option');
button.setAttribute('aria-selected', index === state.selectedIndex ? 'true' : 'false');
const copy = el('span', 'ux-global-search-result-copy');
copy.append(el('strong', '', command.title), el('small', '', command.group));
button.append(copy, el('span', 'ux-global-search-result-action', 'Öffnen'));
button.addEventListener('mouseenter', () => { state.selectedIndex = index; render(); });
button.addEventListener('click', executeSelected);
results.append(button);
});
};
input.addEventListener('input', () => { state.selectedIndex = 0; render(); });
input.addEventListener('keydown', event => {
const count = results.querySelectorAll('[data-view]').length;
if (event.key === 'ArrowDown' && count) { event.preventDefault(); state.selectedIndex = (state.selectedIndex + 1) % count; render(); }
if (event.key === 'ArrowUp' && count) { event.preventDefault(); state.selectedIndex = (state.selectedIndex - 1 + count) % count; render(); }
if (event.key === 'Enter') { event.preventDefault(); executeSelected(); }
if (event.key === 'Escape') closeDialog();
});
close.addEventListener('click', closeDialog);
dialog.addEventListener('mousedown', event => { if (event.target === dialog) closeDialog(); });
dialog._open = () => {
dialog.classList.remove('hidden');
document.body.classList.add('ux-search-open');
state.selectedIndex = 0;
render();
requestAnimationFrame(() => input.focus());
};
dialog._close = closeDialog;
return dialog;
}
function bindSearch() {
const dialog = createDialog();
document.getElementById('topbar-search-btn')?.addEventListener('click', () => dialog._open());
document.addEventListener('keydown', event => {
const modifier = /Mac|iPhone|iPad/.test(navigator.platform) ? event.metaKey : event.ctrlKey;
if (modifier && event.key.toLocaleLowerCase('de-DE') === 'k') {
event.preventDefault();
dialog.classList.contains('hidden') ? dialog._open() : dialog._close();
}
if (event.altKey && event.key === 'ArrowLeft') { event.preventDefault(); history.back(); }
if (event.altKey && event.key === 'ArrowRight') { event.preventDefault(); history.forward(); }
if (event.key === 'Escape' && !dialog.classList.contains('hidden')) dialog._close();
});
}
function observeNavigation() {
const nav = document.querySelector('.sidebar-nav');
if (!nav) return;
const observer = new MutationObserver(() => {
const next = getActiveView();
if (!next || next === state.activeView || state.restoring) return;
state.activeView = next;
const url = new URL(window.location.href);
url.hash = `view=${encodeURIComponent(next)}`;
history.pushState({ vendooView: next }, '', url);
updateHistoryButtons();
});
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, { attributes: true, attributeFilter: ['class'] }));
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
}
window.addEventListener('popstate', event => {
const view = event.state?.vendooView || new URLSearchParams(window.location.hash.replace(/^#/, '')).get('view');
if (!view || !getNavItem(view)) return;
state.restoring = true;
getNavItem(view).click();
requestAnimationFrame(() => {
state.activeView = getActiveView();
state.restoring = false;
updateHistoryButtons();
});
});
function initialize() {
createHistoryControls();
ensureInitialHistoryState();
bindSearch();
observeNavigation();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', initialize, { once: true });
else initialize();
})();