478 lines
19 KiB
JavaScript
478 lines
19 KiB
JavaScript
(() => {
|
|
'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 setText = (node, value) => {
|
|
if (!node) return;
|
|
const normalized = String(value ?? '');
|
|
if (node.textContent !== normalized) node.textContent = normalized;
|
|
};
|
|
|
|
const setData = (node, name, value) => {
|
|
if (!node) return;
|
|
const normalized = String(value ?? '');
|
|
if (node.dataset[name] !== normalized) node.dataset[name] = normalized;
|
|
};
|
|
|
|
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 });
|
|
});
|
|
};
|
|
|
|
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';
|
|
setData(root, '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-toggle, #flux-job-center-body')
|
|
: root.querySelector('#flux-studio-favorites-only, #flux-studio-history');
|
|
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) {
|
|
setText(jobBadge, jobState.issues ? 'Problem' : jobState.active ? 'Aktiv' : jobState.hasJobs ? 'Verlauf' : '');
|
|
setData(jobBadge, 'tone', jobState.issues ? 'danger' : jobState.active ? 'working' : 'neutral');
|
|
}
|
|
|
|
const libraryBadge = root.querySelector('[data-ux-flux-view-badge="library"]');
|
|
if (libraryBadge) {
|
|
const count = readLibraryCount(root);
|
|
setText(libraryBadge, count ? String(count) : '');
|
|
setData(libraryBadge, '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 = Boolean(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';
|
|
|
|
setData(root, '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));
|
|
setText(step.querySelector('.ux-flux-step-state'), completed[id] ? 'Erledigt' : active ? 'Jetzt' : 'Offen');
|
|
});
|
|
|
|
const next = root.querySelector('[data-ux-flux-next]');
|
|
if (next) {
|
|
if (!completed.prompt) {
|
|
setData(next, 'uxFluxNext', 'prompt');
|
|
setText(next, 'Prompt eingeben');
|
|
} else if (jobState.issues || jobState.active || isBusy) {
|
|
setData(next, 'uxFluxNext', 'jobs');
|
|
setText(next, jobState.issues ? 'Probleme prüfen' : 'Auftrag überwachen');
|
|
} else if (hasResult) {
|
|
setData(next, 'uxFluxNext', 'result');
|
|
setText(next, 'Ergebnis prüfen');
|
|
} else {
|
|
setData(next, 'uxFluxNext', 'generate');
|
|
setText(next, '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);
|
|
const targets = [
|
|
root.querySelector('#flux-job-summary'),
|
|
root.querySelector('#flux-job-list'),
|
|
root.querySelector('#flux-history-page-info'),
|
|
root.querySelector('#flux-studio-history'),
|
|
root.querySelector('#flux-studio-preview-image'),
|
|
root.querySelector('#flux-studio-progress'),
|
|
root.querySelector('#flux-studio-status'),
|
|
].filter(Boolean);
|
|
targets.forEach(target => observer.observe(target, {
|
|
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();
|
|
}
|
|
})(); |