feat(ux): Navigation Engine und Command Registry ergänzen
This commit is contained in:
@@ -0,0 +1,315 @@
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
if (document.documentElement.dataset.uxCommandPlatformReady === '1') return;
|
||||
document.documentElement.dataset.uxCommandPlatformReady = '1';
|
||||
|
||||
const FAVORITES_KEY = 'vendoo_command_favorites_v1';
|
||||
const RECENTS_KEY = 'vendoo_navigation_recents_v1';
|
||||
const MAX_RECENTS = 12;
|
||||
const registry = new Map();
|
||||
const listeners = new Set();
|
||||
|
||||
const VIEW_META = {
|
||||
dashboard: { title: 'Übersicht', group: 'Start', icon: '⌂', keywords: ['dashboard', 'start'] },
|
||||
today: { title: 'Aufgaben', group: 'Start', icon: '✓', keywords: ['heute', 'tagesarbeitsplatz'] },
|
||||
generator: { title: 'Neuer Artikel', group: 'Erstellen', icon: '+', keywords: ['generator', 'listing erstellen'] },
|
||||
history: { title: 'Artikel', group: 'Verkaufen', icon: '□', keywords: ['listings', 'artikelübersicht'] },
|
||||
'quality-center': { title: 'Qualität', group: 'Optimieren', icon: '◇', keywords: ['qualitätscenter', 'prüfen'] },
|
||||
publish: { title: 'Veröffentlichen', group: 'Verkaufen', icon: '↗', keywords: ['publish', 'marktplätze'] },
|
||||
inventory: { title: 'Lager', group: 'Bestand', icon: '▣', keywords: ['bestand', 'lagerorte'] },
|
||||
templates: { title: 'Vorlagen', group: 'Inhalte', icon: '▤', keywords: ['templates'] },
|
||||
'media-library': { title: 'Medien', group: 'Inhalte', icon: '▧', keywords: ['bilder', 'galerie'] },
|
||||
'image-factory': { title: 'Bildwerkstatt', group: 'AI & Bilder', icon: '✦', keywords: ['bildproduktion'] },
|
||||
'flux-studio': { title: 'FLUX Studio', group: 'AI & Bilder', icon: '✧', keywords: ['comfyui', 'ai bilder'] },
|
||||
extensions: { title: 'Erweiterungen', group: 'System', icon: '⌘', keywords: ['extensions', 'browser'] },
|
||||
settings: { title: 'Einstellungen', group: 'System', icon: '⚙', keywords: ['verbindungen', 'apis'] },
|
||||
admin: { title: 'Verwaltung', group: 'System', icon: '◈', keywords: ['benutzer', 'admin'] },
|
||||
fees: { title: 'Gebühren', group: 'Verkaufen', icon: '€', keywords: ['kosten'] },
|
||||
trash: { title: 'Papierkorb', group: 'System', icon: '⌫', keywords: ['gelöscht'] },
|
||||
};
|
||||
|
||||
const readList = (key) => {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem(key) || '[]');
|
||||
return Array.isArray(value) ? value.filter(item => typeof item === 'string') : [];
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
const writeList = (key, value) => {
|
||||
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
|
||||
};
|
||||
|
||||
let favorites = readList(FAVORITES_KEY);
|
||||
let recents = readList(RECENTS_KEY);
|
||||
|
||||
const emit = (type, detail = {}) => {
|
||||
window.dispatchEvent(new CustomEvent(`vendoo:${type}`, { detail }));
|
||||
listeners.forEach(listener => {
|
||||
try { listener({ type, detail }); } catch {}
|
||||
});
|
||||
};
|
||||
|
||||
function normalizeCommand(input) {
|
||||
if (!input || typeof input !== 'object') throw new TypeError('Command muss ein Objekt sein.');
|
||||
const id = String(input.id || '').trim();
|
||||
const title = String(input.title || '').trim();
|
||||
if (!id || !title) throw new TypeError('Command benötigt id und title.');
|
||||
const keywords = Array.isArray(input.keywords) ? input.keywords.map(String) : [];
|
||||
return Object.freeze({
|
||||
id,
|
||||
title,
|
||||
description: String(input.description || ''),
|
||||
group: String(input.group || 'Allgemein'),
|
||||
icon: String(input.icon || '•'),
|
||||
keywords,
|
||||
shortcut: String(input.shortcut || ''),
|
||||
permission: input.permission || null,
|
||||
kind: String(input.kind || 'navigation'),
|
||||
view: input.view ? String(input.view) : null,
|
||||
execute: typeof input.execute === 'function' ? input.execute : null,
|
||||
});
|
||||
}
|
||||
|
||||
function register(command) {
|
||||
const normalized = normalizeCommand(command);
|
||||
registry.set(normalized.id, normalized);
|
||||
emit('commands-changed', { commandId: normalized.id });
|
||||
return () => unregister(normalized.id);
|
||||
}
|
||||
|
||||
function unregister(id) {
|
||||
const removed = registry.delete(String(id));
|
||||
if (removed) emit('commands-changed', { commandId: String(id) });
|
||||
return removed;
|
||||
}
|
||||
|
||||
function list({ query = '', group = null } = {}) {
|
||||
const needle = String(query).trim().toLocaleLowerCase('de-DE');
|
||||
return [...registry.values()].filter(command => {
|
||||
if (group && command.group !== group) return false;
|
||||
if (!needle) return true;
|
||||
const haystack = [command.title, command.description, command.group, ...command.keywords].join(' ').toLocaleLowerCase('de-DE');
|
||||
let cursor = 0;
|
||||
for (const character of needle) {
|
||||
cursor = haystack.indexOf(character, cursor);
|
||||
if (cursor < 0) return false;
|
||||
cursor += 1;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function getActiveView() {
|
||||
return document.querySelector('.sidebar-nav .nav-item.active[data-tab]')?.dataset.tab
|
||||
|| document.querySelector('.tab-content.active')?.id
|
||||
|| 'dashboard';
|
||||
}
|
||||
|
||||
function canOpen(view) {
|
||||
const item = document.querySelector(`.sidebar-nav .nav-item[data-tab="${CSS.escape(view)}"]`);
|
||||
return Boolean(item && !item.classList.contains('hidden'));
|
||||
}
|
||||
|
||||
function openView(view) {
|
||||
const item = document.querySelector(`.sidebar-nav .nav-item[data-tab="${CSS.escape(view)}"]`);
|
||||
if (!item || item.classList.contains('hidden')) return false;
|
||||
item.closest('details.ux-nav-section')?.setAttribute('open', '');
|
||||
item.click();
|
||||
return true;
|
||||
}
|
||||
|
||||
function recordRecent(view) {
|
||||
if (!view || !VIEW_META[view]) return;
|
||||
recents = [view, ...recents.filter(item => item !== view)].slice(0, MAX_RECENTS);
|
||||
writeList(RECENTS_KEY, recents);
|
||||
renderNavigationChrome();
|
||||
emit('navigation-changed', { view, recents: [...recents] });
|
||||
}
|
||||
|
||||
function toggleFavorite(commandId) {
|
||||
const id = String(commandId);
|
||||
favorites = favorites.includes(id) ? favorites.filter(item => item !== id) : [id, ...favorites];
|
||||
writeList(FAVORITES_KEY, favorites);
|
||||
renderNavigationChrome();
|
||||
emit('favorites-changed', { commandId: id, favorites: [...favorites] });
|
||||
return favorites.includes(id);
|
||||
}
|
||||
|
||||
function execute(id, context = {}) {
|
||||
const command = registry.get(String(id));
|
||||
if (!command) return false;
|
||||
if (command.view && !canOpen(command.view)) return false;
|
||||
const result = command.execute ? command.execute(context) : (command.view ? openView(command.view) : false);
|
||||
if (result !== false && command.view) recordRecent(command.view);
|
||||
emit('command-executed', { commandId: command.id, view: command.view });
|
||||
return result !== false;
|
||||
}
|
||||
|
||||
function createButton(className, label, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = className;
|
||||
button.textContent = label;
|
||||
button.title = title;
|
||||
return button;
|
||||
}
|
||||
|
||||
function ensureChrome() {
|
||||
const topbar = document.getElementById('topbar');
|
||||
if (!topbar || document.getElementById('ux-command-platform-chrome')) return;
|
||||
const chrome = document.createElement('div');
|
||||
chrome.id = 'ux-command-platform-chrome';
|
||||
chrome.className = 'ux-command-platform-chrome';
|
||||
|
||||
const breadcrumb = document.createElement('nav');
|
||||
breadcrumb.id = 'ux-command-breadcrumb';
|
||||
breadcrumb.className = 'ux-command-breadcrumb';
|
||||
breadcrumb.setAttribute('aria-label', 'Brotkrümelnavigation');
|
||||
|
||||
const favoriteButton = createButton('ux-command-favorite-toggle', '☆', 'Aktuellen Bereich zu Favoriten hinzufügen');
|
||||
favoriteButton.id = 'ux-command-favorite-toggle';
|
||||
favoriteButton.setAttribute('aria-pressed', 'false');
|
||||
|
||||
const drawerButton = createButton('ux-command-drawer-toggle', 'Favoriten', 'Favoriten und zuletzt geöffnete Bereiche anzeigen');
|
||||
drawerButton.id = 'ux-command-drawer-toggle';
|
||||
drawerButton.setAttribute('aria-expanded', 'false');
|
||||
|
||||
const drawer = document.createElement('section');
|
||||
drawer.id = 'ux-command-drawer';
|
||||
drawer.className = 'ux-command-drawer hidden';
|
||||
drawer.setAttribute('aria-label', 'Favoriten und Verlauf');
|
||||
|
||||
favoriteButton.addEventListener('click', () => toggleFavorite(`view:${getActiveView()}`));
|
||||
drawerButton.addEventListener('click', () => {
|
||||
const open = drawer.classList.toggle('hidden') === false;
|
||||
drawerButton.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||
if (open) drawer.querySelector('button')?.focus({ preventScroll: true });
|
||||
});
|
||||
document.addEventListener('keydown', event => {
|
||||
if (event.key === 'Escape' && !drawer.classList.contains('hidden')) {
|
||||
drawer.classList.add('hidden');
|
||||
drawerButton.setAttribute('aria-expanded', 'false');
|
||||
drawerButton.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
|
||||
chrome.append(breadcrumb, favoriteButton, drawerButton, drawer);
|
||||
topbar.insertAdjacentElement('afterend', chrome);
|
||||
}
|
||||
|
||||
function renderNavigationChrome() {
|
||||
ensureChrome();
|
||||
const view = getActiveView();
|
||||
const meta = VIEW_META[view] || { title: view, group: 'Vendoo' };
|
||||
const breadcrumb = document.getElementById('ux-command-breadcrumb');
|
||||
const favoriteButton = document.getElementById('ux-command-favorite-toggle');
|
||||
const drawer = document.getElementById('ux-command-drawer');
|
||||
if (!breadcrumb || !favoriteButton || !drawer) return;
|
||||
|
||||
breadcrumb.replaceChildren();
|
||||
const home = createButton('ux-command-crumb', 'Vendoo', 'Zur Übersicht');
|
||||
home.addEventListener('click', () => execute('view:dashboard'));
|
||||
const separator = document.createElement('span');
|
||||
separator.className = 'ux-command-crumb-separator';
|
||||
separator.textContent = '›';
|
||||
separator.setAttribute('aria-hidden', 'true');
|
||||
const current = document.createElement('span');
|
||||
current.className = 'ux-command-crumb-current';
|
||||
current.textContent = `${meta.group} · ${meta.title}`;
|
||||
breadcrumb.append(home, separator, current);
|
||||
|
||||
const commandId = `view:${view}`;
|
||||
const favorite = favorites.includes(commandId);
|
||||
favoriteButton.textContent = favorite ? '★' : '☆';
|
||||
favoriteButton.setAttribute('aria-pressed', favorite ? 'true' : 'false');
|
||||
favoriteButton.title = favorite ? 'Aus Favoriten entfernen' : 'Aktuellen Bereich zu Favoriten hinzufügen';
|
||||
|
||||
drawer.replaceChildren();
|
||||
const appendSection = (title, ids) => {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'ux-command-drawer-section';
|
||||
const heading = document.createElement('strong');
|
||||
heading.textContent = title;
|
||||
section.append(heading);
|
||||
const valid = ids.map(id => id.startsWith('view:') ? id : `view:${id}`).filter(id => registry.has(id));
|
||||
if (!valid.length) {
|
||||
const empty = document.createElement('span');
|
||||
empty.className = 'ux-command-drawer-empty';
|
||||
empty.textContent = title === 'Favoriten' ? 'Noch keine Favoriten angeheftet.' : 'Noch keine Bereiche geöffnet.';
|
||||
section.append(empty);
|
||||
} else {
|
||||
valid.forEach(id => {
|
||||
const command = registry.get(id);
|
||||
const button = createButton('ux-command-drawer-item', `${command.icon} ${command.title}`, command.description || command.title);
|
||||
button.addEventListener('click', () => {
|
||||
execute(id);
|
||||
drawer.classList.add('hidden');
|
||||
document.getElementById('ux-command-drawer-toggle')?.setAttribute('aria-expanded', 'false');
|
||||
});
|
||||
section.append(button);
|
||||
});
|
||||
}
|
||||
drawer.append(section);
|
||||
};
|
||||
appendSection('Favoriten', favorites);
|
||||
appendSection('Zuletzt geöffnet', recents);
|
||||
}
|
||||
|
||||
function registerCoreNavigation() {
|
||||
Object.entries(VIEW_META).forEach(([view, meta]) => {
|
||||
register({
|
||||
id: `view:${view}`,
|
||||
title: meta.title,
|
||||
description: `${meta.group} öffnen`,
|
||||
group: meta.group,
|
||||
icon: meta.icon,
|
||||
keywords: meta.keywords,
|
||||
kind: 'navigation',
|
||||
view,
|
||||
execute: () => openView(view),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function observeNavigation() {
|
||||
const nav = document.querySelector('.sidebar-nav');
|
||||
if (!nav) return;
|
||||
let current = getActiveView();
|
||||
recordRecent(current);
|
||||
const observer = new MutationObserver(() => {
|
||||
const next = getActiveView();
|
||||
if (!next || next === current) return;
|
||||
current = next;
|
||||
recordRecent(next);
|
||||
});
|
||||
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, { attributes: true, attributeFilter: ['class'] }));
|
||||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||||
}
|
||||
|
||||
window.VendooCommands = Object.freeze({
|
||||
register,
|
||||
unregister,
|
||||
list,
|
||||
get: id => registry.get(String(id)) || null,
|
||||
execute,
|
||||
toggleFavorite,
|
||||
isFavorite: id => favorites.includes(String(id)),
|
||||
get favorites() { return [...favorites]; },
|
||||
subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); },
|
||||
});
|
||||
|
||||
window.VendooNavigation = Object.freeze({
|
||||
get currentView() { return getActiveView(); },
|
||||
get recents() { return [...recents]; },
|
||||
open: openView,
|
||||
back: () => history.back(),
|
||||
forward: () => history.forward(),
|
||||
home: () => execute('view:dashboard'),
|
||||
});
|
||||
|
||||
registerCoreNavigation();
|
||||
ensureChrome();
|
||||
renderNavigationChrome();
|
||||
observeNavigation();
|
||||
})();
|
||||
Reference in New Issue
Block a user