diff --git a/public/module-extension-workspace.js b/public/module-extension-workspace.js new file mode 100644 index 0000000..c7c9003 --- /dev/null +++ b/public/module-extension-workspace.js @@ -0,0 +1,193 @@ +(() => { + 'use strict'; + + const moduleRoot = document.getElementById('admin-modules-panel'); + const extensionRoot = document.getElementById('extensions'); + if ((!moduleRoot && !extensionRoot) || document.documentElement.dataset.uxModuleExtensionReady === '1') return; + document.documentElement.dataset.uxModuleExtensionReady = '1'; + + const storageKey = 'vendoo_module_extension_workspace_v1'; + const state = { moduleFilter: 'all', extensionFilter: 'all' }; + try { Object.assign(state, JSON.parse(localStorage.getItem(storageKey) || '{}')); } catch {} + const persist = () => { try { localStorage.setItem(storageKey, JSON.stringify(state)); } catch {} }; + + const el = (tag, className, text) => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined) node.textContent = text; + return node; + }; + + function makeFilter(label, value, group, active) { + const button = el('button', 'ux-module-filter', label); + button.type = 'button'; + button.dataset.uxModuleFilter = value; + button.dataset.uxModuleGroup = group; + button.setAttribute('aria-pressed', active ? 'true' : 'false'); + return button; + } + + function text(node) { + return String(node?.textContent || '').toLocaleLowerCase('de-DE'); + } + + function setupModules() { + if (!moduleRoot || moduleRoot.dataset.uxWorkspaceReady === '1') return; + moduleRoot.dataset.uxWorkspaceReady = '1'; + + const summary = moduleRoot.querySelector('#module-manager-summary'); + const safety = moduleRoot.querySelector('#module-manager-safety'); + const list = moduleRoot.querySelector('#module-manager-list'); + const search = moduleRoot.querySelector('#module-manager-search'); + const file = moduleRoot.querySelector('#module-manager-file'); + const install = moduleRoot.querySelector('#module-manager-install'); + const refresh = moduleRoot.querySelector('#module-manager-refresh'); + if (!list) return; + + const command = el('section', 'ux-module-command'); + command.setAttribute('aria-label', 'Modulverwaltung'); + const copy = el('div', 'ux-module-command-copy'); + copy.append(el('span', 'ux-module-kicker', 'Systemerweiterungen'), el('h3', '', 'Module kontrolliert verwalten'), el('p', '', 'Abhängigkeiten, Vertrauensstatus und Betriebszustand zuerst prüfen. Änderungen bleiben bewusste Einzelaktionen.')); + const health = el('div', 'ux-module-health'); + command.append(copy, health); + + const filters = el('div', 'ux-module-filterbar'); + [['Alle', 'all'], ['Aktiv', 'active'], ['Deaktiviert', 'disabled'], ['Installiert', 'external'], ['Probleme', 'problems']] + .forEach(([label, value]) => filters.append(makeFilter(label, value, 'modules', state.moduleFilter === value))); + + const installDetails = el('details', 'ux-module-install'); + const installSummary = el('summary'); + installSummary.append(el('strong', '', 'Modulpaket installieren'), el('small', '', 'Nur geprüfte .vmod-Pakete')); + const installBody = el('div', 'ux-module-install-body'); + if (file) installBody.append(file); + if (install) installBody.append(install); + installDetails.append(installSummary, installBody); + + const tools = el('div', 'ux-module-tools'); + if (search) tools.append(search); + if (refresh) tools.append(refresh); + tools.append(installDetails); + + const anchor = summary || safety || list; + anchor.insertAdjacentElement('beforebegin', command); + command.insertAdjacentElement('afterend', filters); + filters.insertAdjacentElement('afterend', tools); + + function classify(card) { + const value = text(card); + return { + active: value.includes('aktiv') && !value.includes('deaktiviert'), + disabled: value.includes('deaktiviert') || card.classList.contains('is-disabled'), + external: value.includes('installiert'), + problems: value.includes('fehler') || value.includes('ungültig') || value.includes('invalid') || card.classList.contains('is-invalid') || Boolean(card.querySelector('.module-error')), + }; + } + + function refresh() { + const cards = [...list.querySelectorAll('.module-card')]; + const counts = { total: cards.length, active: 0, disabled: 0, external: 0, problems: 0 }; + cards.forEach(card => { + const flags = classify(card); + Object.keys(flags).forEach(key => { if (flags[key]) counts[key] += 1; }); + const visible = state.moduleFilter === 'all' || flags[state.moduleFilter]; + card.hidden = !visible; + card.tabIndex = visible ? 0 : -1; + }); + health.replaceChildren(); + [['Gesamt', counts.total], ['Aktiv', counts.active], ['Installiert', counts.external], ['Probleme', counts.problems]] + .forEach(([label, value]) => { + const item = el('div', 'ux-module-health-item'); + item.append(el('small', '', label), el('strong', '', String(value))); + if (label === 'Probleme' && value > 0) item.dataset.tone = 'danger'; + health.append(item); + }); + filters.querySelectorAll('[data-ux-module-filter]').forEach(button => button.setAttribute('aria-pressed', button.dataset.uxModuleFilter === state.moduleFilter ? 'true' : 'false')); + } + + filters.addEventListener('click', event => { + const button = event.target.closest('[data-ux-module-filter]'); + if (!button) return; + state.moduleFilter = button.dataset.uxModuleFilter; + persist(); + refresh(); + list.querySelector('.module-card:not([hidden])')?.focus({ preventScroll: true }); + }); + + list.addEventListener('keydown', event => { + const card = event.target.closest('.module-card'); + if (!card || !['Enter', ' '].includes(event.key)) return; + if (event.target.closest('button, a, input, select')) return; + event.preventDefault(); + card.querySelector('.module-card-actions button:not(:disabled), .module-card-actions a')?.focus(); + }); + + const observer = new MutationObserver(() => requestAnimationFrame(refresh)); + observer.observe(list, { childList: true }); + refresh(); + window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); + } + + function setupExtensions() { + if (!extensionRoot || extensionRoot.dataset.uxWorkspaceReady === '1') return; + extensionRoot.dataset.uxWorkspaceReady = '1'; + const grid = extensionRoot.querySelector('#extensions-grid'); + const activeBrowser = extensionRoot.querySelector('#extensions-active-browser'); + const prepare = extensionRoot.querySelector('#extensions-prepare-active'); + if (!grid) return; + + const board = el('section', 'ux-extension-board'); + const copy = el('div'); + copy.append(el('span', 'ux-module-kicker', 'Browser-Verbindungen'), el('h3', '', 'Extensions vorbereiten und prüfen'), el('p', '', 'Vendoo bereitet Pakete und Pfade vor. Die endgültige Browserfreigabe bleibt immer manuell.')); + const status = el('div', 'ux-extension-status'); + board.append(copy, status); + + const filters = el('div', 'ux-module-filterbar ux-extension-filterbar'); + [['Alle', 'all'], ['Bereit', 'ready'], ['Verbunden', 'connected'], ['Aktion nötig', 'attention']] + .forEach(([label, value]) => filters.append(makeFilter(label, value, 'extensions', state.extensionFilter === value))); + + grid.insertAdjacentElement('beforebegin', filters); + filters.insertAdjacentElement('beforebegin', board); + if (activeBrowser) status.append(activeBrowser); + if (prepare) status.append(prepare); + + function classify(card) { + const value = text(card); + return { + connected: value.includes('verbunden') || value.includes('aktiv'), + ready: value.includes('bereit') || value.includes('vorbereitet') || value.includes('installiert'), + attention: value.includes('nicht') || value.includes('fehler') || value.includes('aktion') || value.includes('installieren'), + }; + } + + function refresh() { + const cards = [...grid.children].filter(node => node.nodeType === 1); + let connected = 0; + let attention = 0; + cards.forEach(card => { + const flags = classify(card); + if (flags.connected) connected += 1; + if (flags.attention) attention += 1; + card.hidden = !(state.extensionFilter === 'all' || flags[state.extensionFilter]); + }); + board.dataset.connected = String(connected); + board.dataset.attention = String(attention); + filters.querySelectorAll('[data-ux-module-filter]').forEach(button => button.setAttribute('aria-pressed', button.dataset.uxModuleFilter === state.extensionFilter ? 'true' : 'false')); + } + + filters.addEventListener('click', event => { + const button = event.target.closest('[data-ux-module-filter]'); + if (!button) return; + state.extensionFilter = button.dataset.uxModuleFilter; + persist(); + refresh(); + }); + + const observer = new MutationObserver(() => requestAnimationFrame(refresh)); + observer.observe(grid, { childList: true, subtree: true, characterData: true }); + refresh(); + window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); + } + + setupModules(); + setupExtensions(); +})(); \ No newline at end of file