feat(ux): add guided FLUX Studio workspace

This commit is contained in:
Masterluke77
2026-07-12 02:00:28 +02:00
parent 01ff755f53
commit 6423674897
+463
View File
@@ -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();
}
})();