feat: globale Suche und Navigationsverlauf ergänzen
This commit is contained in:
@@ -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();
|
||||
})();
|
||||
Reference in New Issue
Block a user