Files
vendoo/public/command-platform.js
T

454 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(() => {
'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();
})();