feat(ux): add guided FLUX Studio workspace
This commit is contained in:
@@ -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();
|
||||
}
|
||||
})();
|
||||
Reference in New Issue
Block a user