From 64236748971812c8999aa3f343ec95e41d2c4b4b Mon Sep 17 00:00:00 2001 From: Masterluke77 <163446896+Masterluke77@users.noreply.github.com> Date: Sun, 12 Jul 2026 02:00:28 +0200 Subject: [PATCH] feat(ux): add guided FLUX Studio workspace --- public/flux-studio-ux.js | 463 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 463 insertions(+) create mode 100644 public/flux-studio-ux.js diff --git a/public/flux-studio-ux.js b/public/flux-studio-ux.js new file mode 100644 index 0000000..ad02d2d --- /dev/null +++ b/public/flux-studio-ux.js @@ -0,0 +1,463 @@ +(() => { + 'use strict'; + + const ROOT_ID = 'flux-studio'; + const VIEW_KEY = 'vendoo_flux_studio_view'; + const VALID_VIEWS = ['create', 'jobs', 'library']; + + const createNode = (tag, className = '', text = '') => { + const node = document.createElement(tag); + if (className) node.className = className; + if (text) node.textContent = text; + return node; + }; + + const readStoredView = () => { + try { + const stored = localStorage.getItem(VIEW_KEY) || 'create'; + return VALID_VIEWS.includes(stored) ? stored : 'create'; + } catch { + return 'create'; + } + }; + + const storeView = view => { + try { localStorage.setItem(VIEW_KEY, view); } catch {} + }; + + const focusNode = node => { + if (!node) return; + node.scrollIntoView({ behavior: 'smooth', block: 'center' }); + window.requestAnimationFrame(() => { + const target = node.matches('input,button,select,textarea,summary,a[href]') + ? node + : node.querySelector('input,button,select,textarea,summary,a[href]'); + target?.focus({ preventScroll: true }); + }); + }; + + const parseNumber = value => { + const match = String(value || '').replace(/\./g, '').match(/\d+/); + return match ? Number(match[0]) : 0; + }; + + function createViewButton(view, label, copy) { + const button = createNode('button', 'ux-flux-view-tab'); + button.type = 'button'; + button.dataset.uxFluxView = view; + button.setAttribute('role', 'tab'); + button.setAttribute('aria-selected', 'false'); + + const body = createNode('span', 'ux-flux-view-copy'); + body.append(createNode('strong', '', label), createNode('small', '', copy)); + const badge = createNode('span', 'ux-flux-view-badge'); + badge.dataset.uxFluxViewBadge = view; + button.append(body, badge); + return button; + } + + function createViewNavigation(root) { + if (root.querySelector('.ux-flux-view-navigation')) return; + const header = root.querySelector('.flux-studio-heading'); + const layout = root.querySelector('.flux-studio-layout'); + if (!header || !layout) return; + + const navigation = createNode('nav', 'ux-flux-view-navigation'); + navigation.setAttribute('aria-label', 'FLUX Studio Arbeitsbereiche'); + navigation.setAttribute('role', 'tablist'); + navigation.append( + createViewButton('create', 'Erstellen', 'Prompt, Profil und Vorschau'), + createViewButton('jobs', 'Aufträge', 'Warteschlange, Fehler und Archiv'), + createViewButton('library', 'Bibliothek', 'Ergebnisse und Favoriten') + ); + header.after(navigation); + } + + function setView(root, view, { persist = true, focus = false } = {}) { + const normalized = VALID_VIEWS.includes(view) ? view : 'create'; + root.dataset.uxFluxView = normalized; + if (persist) storeView(normalized); + + root.querySelectorAll('[data-ux-flux-view]').forEach(button => { + const active = button.dataset.uxFluxView === normalized; + button.classList.toggle('is-active', active); + button.setAttribute('aria-selected', String(active)); + button.tabIndex = active ? 0 : -1; + }); + + const panels = { + create: root.querySelector('.flux-studio-layout'), + jobs: root.querySelector('.flux-job-center'), + library: root.querySelector('.flux-studio-history-section'), + }; + Object.entries(panels).forEach(([id, panel]) => { + if (!panel) return; + const active = id === normalized; + panel.classList.toggle('ux-flux-panel-hidden', !active); + panel.setAttribute('aria-hidden', String(!active)); + }); + + if (focus) { + const target = normalized === 'create' + ? root.querySelector('#flux-studio-prompt') + : normalized === 'jobs' + ? root.querySelector('#flux-job-center-body, #flux-job-center-toggle') + : root.querySelector('#flux-studio-history, #flux-studio-favorites-only'); + focusNode(target); + } + } + + function createWorkflow(root) { + if (root.querySelector('.ux-flux-workflow')) return; + const navigation = root.querySelector('.ux-flux-view-navigation'); + if (!navigation) return; + + const workflow = createNode('section', 'ux-flux-workflow'); + workflow.setAttribute('aria-label', 'Geführter FLUX-Ablauf'); + + const copy = createNode('div', 'ux-flux-workflow-copy'); + copy.append( + createNode('span', 'ux-flux-kicker', 'Geführte Bildgenerierung'), + createNode('h3', '', 'Vom Prompt zum verwendbaren Produktbild'), + createNode('p', '', 'Die wichtigsten Schritte bleiben sichtbar. Systemdetails, Auftragsverwaltung und Bibliothek sind getrennt erreichbar.') + ); + + const steps = createNode('div', 'ux-flux-steps'); + const definitions = [ + ['prompt', '1', 'Prompt', 'Motiv und Bildsprache beschreiben'], + ['profile', '2', 'Profil', 'Varianten und Qualitätsziel wählen'], + ['result', '3', 'Ergebnis', 'Bild prüfen und übernehmen'], + ['monitor', '4', 'Betrieb', 'Aufträge und Fehler überwachen'], + ]; + definitions.forEach(([id, number, title, detail]) => { + const button = createNode('button', 'ux-flux-step'); + button.type = 'button'; + button.dataset.uxFluxStep = id; + button.setAttribute('aria-pressed', 'false'); + button.append( + createNode('span', 'ux-flux-step-index', number), + createNode('strong', '', title), + createNode('small', '', detail), + createNode('span', 'ux-flux-step-state', 'Offen') + ); + steps.append(button); + }); + + const next = createNode('button', 'vd-button primary ux-flux-next', 'Prompt eingeben'); + next.type = 'button'; + next.dataset.uxFluxNext = 'prompt'; + workflow.append(copy, steps, next); + navigation.after(workflow); + } + + function createSystemPanel(root) { + if (root.querySelector('.ux-flux-system-panel')) return; + const headingActions = root.querySelector('.flux-studio-engine-actions'); + const workflow = root.querySelector('.ux-flux-workflow'); + const runtime = root.querySelector('.flux-runtime-card'); + if (!headingActions || !workflow || !runtime) return; + + const reset = headingActions.querySelector('#flux-studio-reset'); + const chip = headingActions.querySelector('#flux-studio-engine-chip'); + const controls = [ + headingActions.querySelector('#flux-studio-refresh-engine'), + headingActions.querySelector('#flux-studio-start-engine'), + headingActions.querySelector('#flux-studio-stop-engine'), + ].filter(Boolean); + + const panel = createNode('details', 'ux-flux-system-panel'); + const summary = createNode('summary'); + const summaryCopy = createNode('span', 'ux-flux-system-summary-copy'); + summaryCopy.append( + createNode('strong', '', 'FLUX-System & ComfyUI'), + createNode('small', '', 'Engine-Status, Start/Stop und Laufzeitdaten') + ); + if (chip) summary.append(summaryCopy, chip); + else summary.append(summaryCopy); + + const body = createNode('div', 'ux-flux-system-body'); + const actionBar = createNode('div', 'ux-flux-system-actions'); + controls.forEach(button => actionBar.append(button)); + body.append(actionBar, runtime); + panel.append(summary, body); + workflow.after(panel); + + headingActions.replaceChildren(); + if (reset) headingActions.append(reset); + } + + function groupAdvancedControls(root) { + const controls = root.querySelector('.flux-studio-controls'); + if (!controls || controls.querySelector('.ux-flux-advanced')) return; + const fields = controls.querySelector('.flux-studio-fields'); + const lock = controls.querySelector('.flux-lock-toggle'); + const runRow = controls.querySelector('.flux-studio-run-row'); + if (!fields || !runRow) return; + + const advanced = createNode('details', 'ux-flux-advanced'); + const summary = createNode('summary'); + const copy = createNode('span'); + copy.append( + createNode('strong', '', 'Erweiterte Einstellungen'), + createNode('small', '', 'Prompt-Modus, Stil, Format, Schritte und Seed') + ); + summary.append(copy, createNode('span', 'ux-flux-advanced-hint', 'Optional')); + const body = createNode('div', 'ux-flux-advanced-body'); + if (lock) body.append(lock); + body.append(fields); + advanced.append(summary, body); + runRow.before(advanced); + } + + function simplifyPreviewActions(root) { + const actions = root.querySelector('#flux-studio-preview-actions'); + if (!actions || actions.dataset.uxFluxStructured === 'true') return; + + const primary = createNode('div', 'ux-flux-preview-primary'); + const more = createNode('details', 'ux-flux-preview-more'); + const summary = createNode('summary', '', 'Weitere Bildaktionen'); + const moreBody = createNode('div', 'ux-flux-preview-more-body'); + + const edit = actions.querySelector('#flux-studio-edit'); + const use = actions.querySelector('#flux-studio-use-in-generator'); + const similar = actions.querySelector('#flux-studio-similar'); + const favorite = actions.querySelector('#flux-studio-favorite'); + const download = actions.querySelector('#flux-studio-download'); + const remove = actions.querySelector('#flux-studio-delete'); + + if (use) primary.append(use); + if (edit) primary.append(edit); + [similar, favorite, download, remove].filter(Boolean).forEach(node => moreBody.append(node)); + more.append(summary, moreBody); + actions.append(primary, more); + actions.dataset.uxFluxStructured = 'true'; + } + + function createJobQuickFilters(root) { + const center = root.querySelector('.flux-job-center'); + const select = root.querySelector('#flux-job-filter'); + if (!center || !select || center.querySelector('.ux-flux-job-quick-filters')) return; + + const filters = createNode('div', 'ux-flux-job-quick-filters'); + filters.setAttribute('role', 'group'); + filters.setAttribute('aria-label', 'FLUX-Aufträge schnell filtern'); + const definitions = [ + ['', 'Alle'], + ['active', 'Aktiv'], + ['completed', 'Erfolgreich'], + ['failed', 'Fehler'], + ['cancelled', 'Abgebrochen'], + ]; + definitions.forEach(([value, label]) => { + const button = createNode('button', 'vd-button compact', label); + button.type = 'button'; + button.dataset.uxFluxJobFilter = value || 'all'; + button.addEventListener('click', () => { + select.value = value; + select.dispatchEvent(new Event('change', { bubbles: true })); + updateJobFilters(root); + }); + filters.append(button); + }); + + const head = center.querySelector('.flux-job-center-head'); + head?.after(filters); + } + + function updateJobFilters(root) { + const value = root.querySelector('#flux-job-filter')?.value || ''; + root.querySelectorAll('[data-ux-flux-job-filter]').forEach(button => { + const active = button.dataset.uxFluxJobFilter === (value || 'all'); + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', String(active)); + }); + } + + function readJobState(root) { + const summary = root.querySelector('#flux-job-summary'); + const text = (summary?.textContent || '').toLowerCase(); + const listText = (root.querySelector('#flux-job-list')?.textContent || '').toLowerCase(); + const combined = `${text} ${listText}`; + const active = /wartende varianten\s*[1-9]|laufende varianten\s*[1-9]|wartend|läuft|running|queued|cancelling/.test(combined); + const issues = /fehlgeschlagen|fehler|failed|teilweise fertig|completed_with_errors/.test(combined); + const hasJobs = Boolean(root.querySelector('#flux-job-list')?.children.length) && !/keine aufträge|werden geladen/.test(listText); + return { active, issues, hasJobs }; + } + + function readLibraryCount(root) { + const info = root.querySelector('#flux-history-page-info')?.textContent || ''; + const explicit = info.match(/·\s*(\d+)\s+Bilder?/i); + if (explicit) return Number(explicit[1]); + const history = root.querySelector('#flux-studio-history'); + if (!history || /keine flux-bilder|wird geladen/.test((history.textContent || '').toLowerCase())) return 0; + return [...history.children].filter(node => !node.classList.contains('empty-state')).length; + } + + function updateBadges(root) { + const jobState = readJobState(root); + const jobBadge = root.querySelector('[data-ux-flux-view-badge="jobs"]'); + if (jobBadge) { + jobBadge.textContent = jobState.issues ? 'Problem' : jobState.active ? 'Aktiv' : jobState.hasJobs ? 'Verlauf' : ''; + jobBadge.dataset.tone = jobState.issues ? 'danger' : jobState.active ? 'working' : 'neutral'; + } + + const libraryBadge = root.querySelector('[data-ux-flux-view-badge="library"]'); + if (libraryBadge) { + const count = readLibraryCount(root); + libraryBadge.textContent = count ? String(count) : ''; + libraryBadge.dataset.tone = 'neutral'; + } + } + + function updateWorkflow(root) { + const prompt = root.querySelector('#flux-studio-prompt')?.value.trim() || ''; + const profile = root.querySelector('[data-flux-profile].active') || root.querySelector('[data-flux-profile="balanced"]'); + const variants = root.querySelector('[data-flux-variants].active') || root.querySelector('[data-flux-variants="1"]'); + const image = root.querySelector('#flux-studio-preview-image'); + const hasResult = Boolean(image?.getAttribute('src')) && !image.classList.contains('hidden'); + const progress = root.querySelector('#flux-studio-progress'); + const isBusy = progress && !progress.classList.contains('hidden'); + const jobState = readJobState(root); + + const completed = { + prompt: prompt.length >= 3, + profile: Boolean(profile && variants), + result: hasResult, + monitor: jobState.hasJobs && !jobState.active && !jobState.issues, + }; + + const current = !completed.prompt + ? 'prompt' + : jobState.issues || jobState.active || isBusy + ? 'monitor' + : hasResult + ? 'result' + : 'profile'; + + root.dataset.uxFluxCurrentStep = current; + root.querySelectorAll('[data-ux-flux-step]').forEach(step => { + const id = step.dataset.uxFluxStep; + const active = id === current; + step.classList.toggle('is-current', active); + step.classList.toggle('is-complete', Boolean(completed[id])); + step.setAttribute('aria-pressed', String(active)); + const state = step.querySelector('.ux-flux-step-state'); + if (state) state.textContent = completed[id] ? 'Erledigt' : active ? 'Jetzt' : 'Offen'; + }); + + const next = root.querySelector('[data-ux-flux-next]'); + if (next) { + if (!completed.prompt) { + next.dataset.uxFluxNext = 'prompt'; + next.textContent = 'Prompt eingeben'; + } else if (jobState.issues || jobState.active || isBusy) { + next.dataset.uxFluxNext = 'jobs'; + next.textContent = jobState.issues ? 'Probleme prüfen' : 'Auftrag überwachen'; + } else if (hasResult) { + next.dataset.uxFluxNext = 'result'; + next.textContent = 'Ergebnis prüfen'; + } else { + next.dataset.uxFluxNext = 'generate'; + next.textContent = 'Profil prüfen & Auftrag starten'; + } + } + + updateBadges(root); + updateJobFilters(root); + } + + function handleStep(root, step) { + if (step === 'jobs' || step === 'monitor') { + setView(root, 'jobs', { focus: true }); + return; + } + if (step === 'library') { + setView(root, 'library', { focus: true }); + return; + } + + setView(root, 'create'); + const targets = { + prompt: root.querySelector('#flux-studio-prompt'), + profile: root.querySelector('.flux-profile-strip'), + generate: root.querySelector('#flux-studio-generate'), + result: root.querySelector('.flux-studio-preview-card'), + }; + focusNode(targets[step] || targets.prompt); + } + + function bindInteractions(root) { + root.querySelectorAll('[data-ux-flux-view]').forEach(button => { + button.addEventListener('click', () => setView(root, button.dataset.uxFluxView, { focus: true })); + button.addEventListener('keydown', event => { + if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return; + event.preventDefault(); + const tabs = [...root.querySelectorAll('[data-ux-flux-view]')]; + const index = tabs.indexOf(button); + const nextIndex = event.key === 'Home' ? 0 + : event.key === 'End' ? tabs.length - 1 + : event.key === 'ArrowRight' ? (index + 1) % tabs.length + : (index - 1 + tabs.length) % tabs.length; + tabs[nextIndex]?.focus(); + tabs[nextIndex]?.click(); + }); + }); + + root.querySelectorAll('[data-ux-flux-step]').forEach(button => { + button.addEventListener('click', () => handleStep(root, button.dataset.uxFluxStep)); + }); + root.querySelector('[data-ux-flux-next]')?.addEventListener('click', event => { + handleStep(root, event.currentTarget.dataset.uxFluxNext); + }); + root.querySelector('#flux-job-filter')?.addEventListener('change', () => updateJobFilters(root)); + } + + function observeState(root) { + let scheduled = false; + const schedule = () => { + if (scheduled) return; + scheduled = true; + window.requestAnimationFrame(() => { + scheduled = false; + updateWorkflow(root); + }); + }; + + const observer = new MutationObserver(schedule); + observer.observe(root, { + subtree: true, + childList: true, + characterData: true, + attributes: true, + attributeFilter: ['class', 'src', 'data-state', 'data-tone', 'disabled'], + }); + root.querySelectorAll('input,textarea,select').forEach(field => { + field.addEventListener('input', schedule); + field.addEventListener('change', schedule); + }); + window.addEventListener('beforeunload', () => observer.disconnect(), { once: true }); + } + + function initializeFluxStudioUx() { + const root = document.getElementById(ROOT_ID); + if (!root || root.classList.contains('ux-flux-studio-ready')) return; + + createViewNavigation(root); + createWorkflow(root); + createSystemPanel(root); + groupAdvancedControls(root); + simplifyPreviewActions(root); + createJobQuickFilters(root); + bindInteractions(root); + setView(root, readStoredView(), { persist: false }); + updateWorkflow(root); + observeState(root); + root.classList.add('ux-flux-studio-ready'); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeFluxStudioUx, { once: true }); + } else { + initializeFluxStudioUx(); + } +})(); \ No newline at end of file