454 lines
19 KiB
JavaScript
454 lines
19 KiB
JavaScript
(() => {
|
||
'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'], aliases: ['home'] },
|
||
today: { title: 'Aufgaben', group: 'Start', icon: '✓', keywords: ['heute', 'tagesarbeitsplatz'], aliases: ['today'] },
|
||
generator: { title: 'Neuer Artikel', group: 'Erstellen', icon: '+', keywords: ['generator', 'listing erstellen'], aliases: ['artikel erstellen'] },
|
||
history: { title: 'Artikel', group: 'Verkaufen', icon: '□', keywords: ['listings', 'artikelübersicht'], aliases: ['historie'] },
|
||
'quality-center': { title: 'Qualität', group: 'Optimieren', icon: '◇', keywords: ['qualitätscenter', 'prüfen'], aliases: ['quality'] },
|
||
publish: { title: 'Veröffentlichen', group: 'Verkaufen', icon: '↗', keywords: ['publish', 'marktplätze'], aliases: ['publishing'] },
|
||
inventory: { title: 'Lager', group: 'Bestand', icon: '▣', keywords: ['bestand', 'lagerorte'], aliases: ['inventory'] },
|
||
templates: { title: 'Vorlagen', group: 'Inhalte', icon: '▤', keywords: ['templates'], aliases: ['template'] },
|
||
'media-library': { title: 'Medien', group: 'Inhalte', icon: '▧', keywords: ['bilder', 'galerie'], aliases: ['medienbibliothek'] },
|
||
'image-factory': { title: 'Bildwerkstatt', group: 'AI & Bilder', icon: '✦', keywords: ['bildproduktion'], aliases: ['image factory'] },
|
||
'flux-studio': { title: 'FLUX Studio', group: 'AI & Bilder', icon: '✧', keywords: ['comfyui', 'ai bilder'], aliases: ['flux'] },
|
||
extensions: { title: 'Erweiterungen', group: 'System', icon: '⌘', keywords: ['extensions', 'browser'], aliases: ['browser erweiterung'] },
|
||
settings: { title: 'Einstellungen', group: 'System', icon: '⚙', keywords: ['verbindungen', 'apis'], aliases: ['settings'] },
|
||
admin: { title: 'Verwaltung', group: 'System', icon: '◈', keywords: ['benutzer', 'admin'], aliases: ['administration'] },
|
||
fees: { title: 'Gebühren', group: 'Verkaufen', icon: '€', keywords: ['kosten'], aliases: ['fees'] },
|
||
trash: { title: 'Papierkorb', group: 'System', icon: '⌫', keywords: ['gelöscht'], aliases: ['trash'] },
|
||
};
|
||
|
||
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 {}
|
||
});
|
||
};
|
||
|
||
const normalizeSearchText = value => String(value || '')
|
||
.normalize('NFKD')
|
||
.replace(/[\u0300-\u036f]/g, '')
|
||
.toLocaleLowerCase('de-DE')
|
||
.trim();
|
||
|
||
const normalizeStringList = value => Array.isArray(value)
|
||
? [...new Set(value.map(item => String(item || '').trim()).filter(Boolean))]
|
||
: [];
|
||
|
||
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.');
|
||
return Object.freeze({
|
||
id,
|
||
title,
|
||
description: String(input.description || ''),
|
||
group: String(input.group || 'Allgemein'),
|
||
icon: String(input.icon || '•'),
|
||
keywords: normalizeStringList(input.keywords),
|
||
aliases: normalizeStringList(input.aliases),
|
||
shortcut: String(input.shortcut || ''),
|
||
permission: input.permission ?? null,
|
||
visible: input.visible ?? null,
|
||
enabled: input.enabled ?? null,
|
||
disabledReason: String(input.disabledReason || ''),
|
||
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 commandId = String(id);
|
||
const removed = registry.delete(commandId);
|
||
if (removed) {
|
||
favorites = favorites.filter(item => item !== commandId);
|
||
writeList(FAVORITES_KEY, favorites);
|
||
emit('commands-changed', { commandId });
|
||
}
|
||
return removed;
|
||
}
|
||
|
||
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 canOpen(view) {
|
||
const item = getNavItem(view);
|
||
return Boolean(item && !item.classList.contains('hidden') && item.getAttribute('aria-hidden') !== 'true');
|
||
}
|
||
|
||
function openView(view) {
|
||
const item = getNavItem(view);
|
||
if (!item || !canOpen(view)) return false;
|
||
item.closest('details.ux-nav-section')?.setAttribute('open', '');
|
||
item.click();
|
||
return true;
|
||
}
|
||
|
||
function evaluateRule(rule, context, fallback) {
|
||
if (typeof rule === 'function') {
|
||
try { return Boolean(rule(context)); } catch { return false; }
|
||
}
|
||
if (typeof rule === 'boolean') return rule;
|
||
return fallback;
|
||
}
|
||
|
||
function evaluatePermission(permission, context) {
|
||
if (permission === null || permission === undefined || permission === '') return true;
|
||
if (typeof permission === 'function') {
|
||
try { return Boolean(permission(context)); } catch { return false; }
|
||
}
|
||
if (typeof permission === 'boolean') return permission;
|
||
const resolver = context?.canPermission || window.VendooPermissions?.can;
|
||
if (typeof resolver !== 'function') return false;
|
||
try {
|
||
if (Array.isArray(permission)) return permission.every(value => resolver.call(window.VendooPermissions, value, context));
|
||
return Boolean(resolver.call(window.VendooPermissions, permission, context));
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function resolveCommand(commandOrId, context = {}) {
|
||
const command = typeof commandOrId === 'string' ? registry.get(commandOrId) : commandOrId;
|
||
if (!command) return null;
|
||
const viewVisible = !command.view || canOpen(command.view);
|
||
const visible = viewVisible && evaluateRule(command.visible, context, true) && evaluatePermission(command.permission, context);
|
||
const enabled = visible && evaluateRule(command.enabled, context, true);
|
||
const disabledReason = enabled
|
||
? ''
|
||
: (command.disabledReason || (!viewVisible ? 'Dieser Bereich ist derzeit nicht verfügbar.' : 'Dieser Befehl ist derzeit nicht verfügbar.'));
|
||
return Object.freeze({ visible, enabled, disabledReason });
|
||
}
|
||
|
||
function subsequenceScore(value, needle) {
|
||
if (!value || !needle) return 0;
|
||
let cursor = 0;
|
||
let gaps = 0;
|
||
for (const character of needle) {
|
||
const next = value.indexOf(character, cursor);
|
||
if (next < 0) return 0;
|
||
gaps += next - cursor;
|
||
cursor = next + 1;
|
||
}
|
||
return Math.max(1, 90 - gaps);
|
||
}
|
||
|
||
function scoreField(value, needle, weights) {
|
||
const normalized = normalizeSearchText(value);
|
||
if (!normalized || !needle) return 0;
|
||
if (normalized === needle) return weights.exact;
|
||
if (normalized.startsWith(needle)) return weights.prefix;
|
||
if (normalized.split(/\s+/).some(token => token.startsWith(needle))) return weights.token;
|
||
if (normalized.includes(needle)) return weights.includes;
|
||
return Math.round(subsequenceScore(normalized, needle) * weights.fuzzy);
|
||
}
|
||
|
||
function scoreCommand(command, query) {
|
||
const needle = normalizeSearchText(query);
|
||
if (!needle) return 0;
|
||
const scores = [
|
||
scoreField(command.title, needle, { exact: 1200, prefix: 1000, token: 850, includes: 720, fuzzy: 1.8 }),
|
||
...command.aliases.map(value => scoreField(value, needle, { exact: 1100, prefix: 920, token: 800, includes: 680, fuzzy: 1.6 })),
|
||
...command.keywords.map(value => scoreField(value, needle, { exact: 820, prefix: 700, token: 620, includes: 520, fuzzy: 1.25 })),
|
||
scoreField(command.description, needle, { exact: 500, prefix: 430, token: 360, includes: 300, fuzzy: .8 }),
|
||
scoreField(command.group, needle, { exact: 360, prefix: 320, token: 280, includes: 230, fuzzy: .65 }),
|
||
];
|
||
return Math.max(0, ...scores);
|
||
}
|
||
|
||
function list({ query = '', group = null, context = {}, includeDisabled = true, includeHidden = false } = {}) {
|
||
const needle = normalizeSearchText(query);
|
||
return [...registry.values()]
|
||
.map(command => {
|
||
const status = resolveCommand(command, context);
|
||
const favoriteIndex = favorites.indexOf(command.id);
|
||
const recentView = command.view ? recents.indexOf(command.view) : -1;
|
||
const score = scoreCommand(command, needle)
|
||
+ (favoriteIndex >= 0 ? Math.max(40, 120 - favoriteIndex * 8) : 0)
|
||
+ (recentView >= 0 ? Math.max(20, 80 - recentView * 5) : 0);
|
||
return Object.freeze({
|
||
...command,
|
||
...status,
|
||
favorite: favoriteIndex >= 0,
|
||
favoriteIndex,
|
||
recent: recentView >= 0,
|
||
recentIndex: recentView,
|
||
score,
|
||
});
|
||
})
|
||
.filter(command => (!group || command.group === group)
|
||
&& (includeHidden || command.visible)
|
||
&& (includeDisabled || command.enabled)
|
||
&& (!needle || command.score > 0))
|
||
.sort((left, right) => {
|
||
if (needle && right.score !== left.score) return right.score - left.score;
|
||
if (!needle && left.favorite !== right.favorite) return left.favorite ? -1 : 1;
|
||
if (!needle && left.favorite && right.favorite && left.favoriteIndex !== right.favoriteIndex) return left.favoriteIndex - right.favoriteIndex;
|
||
if (!needle && left.recent !== right.recent) return left.recent ? -1 : 1;
|
||
if (!needle && left.recent && right.recent && left.recentIndex !== right.recentIndex) return left.recentIndex - right.recentIndex;
|
||
return left.title.localeCompare(right.title, 'de-DE', { sensitivity: 'base' });
|
||
});
|
||
}
|
||
|
||
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);
|
||
if (!registry.has(id)) return false;
|
||
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;
|
||
const status = resolveCommand(command, context);
|
||
if (!status?.visible || !status.enabled) {
|
||
emit('command-blocked', { commandId: command.id, reason: status?.disabledReason || 'Befehl nicht verfügbar.' });
|
||
return false;
|
||
}
|
||
let result;
|
||
try {
|
||
result = command.execute ? command.execute(context) : (command.view ? openView(command.view) : false);
|
||
} catch (error) {
|
||
emit('command-failed', { commandId: command.id, message: error?.message || String(error) });
|
||
return 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}`)
|
||
.map(id => list().find(command => command.id === id))
|
||
.filter(Boolean);
|
||
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(command => {
|
||
const button = createButton('ux-command-drawer-item', `${command.icon} ${command.title}`, command.description || command.title);
|
||
button.addEventListener('click', () => {
|
||
execute(command.id, { source: 'command-drawer' });
|
||
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,
|
||
aliases: meta.aliases,
|
||
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);
|
||
emit('commands-changed', { reason: 'navigation-visibility' });
|
||
});
|
||
nav.querySelectorAll('.nav-item[data-tab]').forEach(item => observer.observe(item, {
|
||
attributes: true,
|
||
attributeFilter: ['class', 'aria-hidden', 'disabled'],
|
||
}));
|
||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||
}
|
||
|
||
window.VendooCommands = Object.freeze({
|
||
register,
|
||
unregister,
|
||
list,
|
||
get: id => registry.get(String(id)) || null,
|
||
resolve: (id, context = {}) => resolveCommand(String(id), context),
|
||
canExecute: (id, context = {}) => Boolean(resolveCommand(String(id), context)?.enabled),
|
||
execute,
|
||
toggleFavorite,
|
||
isFavorite: id => favorites.includes(String(id)),
|
||
get favorites() { return [...favorites]; },
|
||
subscribe(listener) {
|
||
if (typeof listener !== 'function') throw new TypeError('Listener muss eine Funktion sein.');
|
||
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();
|
||
})();
|