Files
vendoo/public/image-factory-ux.js

367 lines
16 KiB
JavaScript

(() => {
'use strict';
const ROOT_ID = 'image-factory';
const JOB_FILTER_KEY = 'vendoo_image_factory_job_filter';
const JOB_FILTERS = ['all', 'active', 'completed', 'issues'];
const createNode = (tag, className = '', text = '') => {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
};
const scrollToNode = node => {
if (!node) return;
node.scrollIntoView({ behavior: 'smooth', block: 'start' });
window.requestAnimationFrame(() => {
const focusTarget = node.matches('input,button,select,textarea') ? node : node.querySelector('input,button,select,textarea');
focusTarget?.focus({ preventScroll: true });
});
};
function parseSelectionSummary(root) {
const summary = root.querySelector('#image-factory-selection-count')?.textContent || '';
const match = summary.match(/(\d+)\s+Bilder?.*?(\d+)\s+Profile?/i);
if (match) return { sources: Number(match[1]), profiles: Number(match[2]) };
const sourceGrid = root.querySelector('#image-factory-source-grid');
const profileGrid = root.querySelector('#image-factory-profile-grid');
const countSelected = container => {
if (!container) return 0;
const checked = container.querySelectorAll('input[type="checkbox"]:checked, input[type="radio"]:checked').length;
if (checked) return checked;
return container.querySelectorAll('.is-selected, .selected, [aria-pressed="true"]').length;
};
return { sources: countSelected(sourceGrid), profiles: countSelected(profileGrid) };
}
function productionCounts(root) {
const read = status => Number(root.querySelector(`[data-batch-summary="${status}"]`)?.textContent || 0);
return {
queued: read('queued'),
running: read('running'),
paused: read('paused'),
completed: read('completed'),
partial: read('partial'),
failed: read('failed'),
};
}
function createStep(number, id, title, copy) {
const button = createNode('button', 'ux-image-factory-step');
button.type = 'button';
button.dataset.uxFactoryStep = id;
button.setAttribute('aria-pressed', 'false');
const index = createNode('span', 'ux-image-factory-step-index', String(number));
const body = createNode('span', 'ux-image-factory-step-copy');
body.append(createNode('strong', '', title), createNode('small', '', copy));
const state = createNode('span', 'ux-image-factory-step-state', 'Offen');
state.dataset.uxFactoryStepState = id;
button.append(index, body, state);
return button;
}
function createWorkflow(root) {
if (root.querySelector('.ux-image-factory-workflow')) return;
const layout = root.querySelector('.image-factory-layout');
if (!layout) return;
const workflow = createNode('section', 'ux-image-factory-workflow');
workflow.setAttribute('aria-label', 'Ablauf der Bildproduktion');
const heading = createNode('div', 'ux-image-factory-workflow-head');
const headingCopy = createNode('div');
headingCopy.append(
createNode('span', 'ux-image-factory-kicker', 'Geführte Bildproduktion'),
createNode('h3', '', 'Vom Quellbild zur fertigen Ausgabe'),
createNode('p', '', 'Vendoo zeigt nur den jeweils relevanten Arbeitsschritt. Bestehende Profile, Jobs und Dateien bleiben unverändert.')
);
const next = createNode('button', 'vd-button primary ux-image-factory-next', 'Quellbilder auswählen');
next.type = 'button';
next.dataset.uxFactoryNext = 'sources';
heading.append(headingCopy, next);
const steps = createNode('div', 'ux-image-factory-steps');
steps.append(
createStep(1, 'sources', 'Quellbilder', 'Bilder für den Auftrag wählen'),
createStep(2, 'profiles', 'Ausgabeprofile', 'Formate und Qualitätsziele festlegen'),
createStep(3, 'review', 'Prüfen & starten', 'Name, Optionen und Umfang kontrollieren'),
createStep(4, 'jobs', 'Aufträge & Ergebnisse', 'Fortschritt und Fehler überwachen')
);
workflow.append(heading, steps);
layout.before(workflow);
}
function createStage(id, number, title, copy) {
const stage = createNode('section', `ux-image-factory-stage ux-image-factory-stage-${id}`);
stage.dataset.uxFactoryStage = id;
stage.id = `ux-image-factory-stage-${id}`;
const head = createNode('header', 'ux-image-factory-stage-head');
const index = createNode('span', 'ux-image-factory-stage-index', String(number).padStart(2, '0'));
const text = createNode('div');
text.append(createNode('h3', '', title), createNode('p', '', copy));
head.append(index, text);
stage.append(head);
return stage;
}
function restructureConfiguration(root) {
const config = root.querySelector('.image-factory-config');
if (!config || config.dataset.uxFactoryStructured === 'true') return;
const sourceHead = config.querySelector('.image-factory-source-head');
const sourceGrid = config.querySelector('#image-factory-source-grid');
const profileHead = config.querySelector('.image-factory-profile-head');
const profileGrid = config.querySelector('#image-factory-profile-grid');
const nameField = config.querySelector(':scope > label.field');
const options = config.querySelector('.image-factory-options');
const submit = config.querySelector('.image-factory-submit');
if (!sourceHead || !sourceGrid || !profileHead || !profileGrid || !submit) return;
const sourceStage = createStage('sources', 1, 'Quellbilder auswählen', 'Suche oder filtere die Medienbibliothek und markiere die Bilder, die verarbeitet werden sollen.');
const sourceTools = sourceHead.querySelector('.image-factory-source-tools');
const sourceSearch = sourceTools?.querySelector('#image-factory-source-search');
const sourceControls = createNode('details', 'ux-image-factory-source-controls');
const sourceControlsSummary = createNode('summary', '', 'Auswahlwerkzeuge');
const sourceControlsBody = createNode('div', 'ux-image-factory-source-controls-body');
if (sourceTools) {
if (sourceSearch) sourceStage.append(sourceSearch);
[...sourceTools.children].forEach(child => sourceControlsBody.append(child));
sourceControls.append(sourceControlsSummary, sourceControlsBody);
}
sourceStage.append(sourceHead, sourceControls, sourceGrid);
const profileStage = createStage('profiles', 2, 'Ausgabeprofile festlegen', 'Wähle ein oder mehrere Zielformate. Jedes Profil erzeugt pro Quellbild eine eigenständige Ausgabe.');
profileStage.append(profileHead, profileGrid);
const reviewStage = createStage('review', 3, 'Auftrag prüfen und starten', 'Vergib einen Namen, kontrolliere den Ausgabeumfang und aktiviere nur bei Bedarf zusätzliche Verarbeitung.');
if (nameField) reviewStage.append(nameField);
if (options) {
options.open = false;
options.classList.add('ux-image-factory-advanced');
reviewStage.append(options);
}
reviewStage.append(submit);
const originalTitle = config.querySelector(':scope > .section-title-row');
originalTitle?.remove();
config.append(sourceStage, profileStage, reviewStage);
config.dataset.uxFactoryStructured = 'true';
}
function createMonitor(root) {
const status = root.querySelector('.image-factory-status');
const layout = root.querySelector('.image-factory-layout');
if (!status || !layout || root.querySelector('.ux-image-factory-monitor')) return;
const monitor = createNode('details', 'ux-image-factory-monitor');
const summary = createNode('summary');
const summaryCopy = createNode('span');
summaryCopy.append(createNode('strong', '', 'Produktionsmonitor'), createNode('small', '', 'Wartende, laufende und fehlerhafte Aufträge'));
const badge = createNode('span', 'ux-image-factory-monitor-badge', 'Keine aktiven Aufträge');
badge.dataset.uxFactoryMonitorBadge = 'true';
summary.append(summaryCopy, badge);
monitor.append(summary, status);
layout.append(monitor);
}
function createJobControls(root) {
const jobs = root.querySelector('.image-factory-jobs');
const list = root.querySelector('#image-factory-jobs');
if (!jobs || !list || jobs.querySelector('.ux-image-factory-job-filter')) return;
jobs.id = 'ux-image-factory-stage-jobs';
const filter = createNode('div', 'ux-image-factory-job-filter');
filter.setAttribute('role', 'group');
filter.setAttribute('aria-label', 'Produktionsaufträge filtern');
const definitions = [
['all', 'Alle'],
['active', 'Aktiv'],
['completed', 'Fertig'],
['issues', 'Probleme'],
];
definitions.forEach(([value, label]) => {
const button = createNode('button', 'vd-button compact', label);
button.type = 'button';
button.dataset.uxFactoryJobFilter = value;
button.setAttribute('aria-pressed', 'false');
filter.append(button);
});
const titleRow = jobs.querySelector(':scope > .section-title-row');
titleRow?.after(filter);
let stored = 'all';
try { stored = localStorage.getItem(JOB_FILTER_KEY) || 'all'; } catch {}
root.dataset.uxFactoryJobFilter = JOB_FILTERS.includes(stored) ? stored : 'all';
}
function classifyJob(node) {
if (node.classList.contains('empty-state')) return 'empty';
const text = (node.textContent || '').toLowerCase();
if (/fehler|fehlgeschlagen|teilweise|failed|partial|abgebrochen|cancelled/.test(text)) return 'issues';
if (/wartend|läuft|laufend|pausiert|queued|running|paused|in bearbeitung/.test(text)) return 'active';
if (/fertig|abgeschlossen|completed|erfolgreich|success/.test(text)) return 'completed';
return 'other';
}
function applyJobFilter(root) {
const selected = JOB_FILTERS.includes(root.dataset.uxFactoryJobFilter) ? root.dataset.uxFactoryJobFilter : 'all';
root.querySelectorAll('[data-ux-factory-job-filter]').forEach(button => {
const active = button.dataset.uxFactoryJobFilter === selected;
button.classList.toggle('active', active);
button.setAttribute('aria-pressed', String(active));
});
const list = root.querySelector('#image-factory-jobs');
if (!list) return;
[...list.children].forEach(job => {
const category = classifyJob(job);
job.hidden = selected !== 'all' && category !== 'empty' && category !== selected;
});
}
function updateWorkflow(root) {
const selection = parseSelectionSummary(root);
const counts = productionCounts(root);
const hasSources = selection.sources > 0;
const hasProfiles = selection.profiles > 0;
const ready = hasSources && hasProfiles;
const activeJobs = counts.queued + counts.running + counts.paused;
const issues = counts.failed + counts.partial;
const hasJobs = activeJobs + counts.completed + issues > 0;
root.dataset.uxFactoryHasSources = String(hasSources);
root.dataset.uxFactoryHasProfiles = String(hasProfiles);
root.dataset.uxFactoryReady = String(ready);
root.dataset.uxFactoryHasJobs = String(hasJobs);
const current = !hasSources ? 'sources' : !hasProfiles ? 'profiles' : activeJobs || issues ? 'jobs' : 'review';
root.dataset.uxFactoryCurrentStep = current;
const completed = {
sources: hasSources,
profiles: hasProfiles,
review: ready && hasJobs,
jobs: hasJobs && activeJobs === 0 && issues === 0,
};
root.querySelectorAll('[data-ux-factory-step]').forEach(step => {
const id = step.dataset.uxFactoryStep;
const isCurrent = id === current;
step.classList.toggle('is-current', isCurrent);
step.classList.toggle('is-complete', Boolean(completed[id]));
step.setAttribute('aria-pressed', String(isCurrent));
const state = step.querySelector('[data-ux-factory-step-state]');
if (state) state.textContent = completed[id] ? 'Erledigt' : isCurrent ? 'Jetzt' : 'Offen';
});
const next = root.querySelector('[data-ux-factory-next]');
if (next) {
if (!hasSources) {
next.dataset.uxFactoryNext = 'sources';
next.textContent = 'Quellbilder auswählen';
} else if (!hasProfiles) {
next.dataset.uxFactoryNext = 'profiles';
next.textContent = 'Ausgabeprofile wählen';
} else if (activeJobs || issues) {
next.dataset.uxFactoryNext = 'jobs';
next.textContent = issues ? 'Probleme prüfen' : 'Produktion überwachen';
} else {
next.dataset.uxFactoryNext = 'submit';
next.textContent = 'Auftrag prüfen & starten';
}
}
const badge = root.querySelector('[data-ux-factory-monitor-badge]');
if (badge) {
badge.textContent = issues
? `${issues} Problem${issues === 1 ? '' : 'e'}`
: activeJobs
? `${activeJobs} aktiv`
: counts.completed
? `${counts.completed} fertig`
: 'Keine aktiven Aufträge';
badge.dataset.tone = issues ? 'danger' : activeJobs ? 'working' : counts.completed ? 'success' : 'neutral';
}
const monitor = root.querySelector('.ux-image-factory-monitor');
if (monitor && !monitor.dataset.uxFactoryInitialized) {
monitor.open = activeJobs > 0 || issues > 0;
monitor.dataset.uxFactoryInitialized = 'true';
}
applyJobFilter(root);
}
function stageTarget(root, id) {
if (id === 'jobs') return root.querySelector('#ux-image-factory-stage-jobs');
return root.querySelector(`#ux-image-factory-stage-${id}`);
}
function bindActions(root) {
root.addEventListener('click', event => {
const step = event.target.closest('[data-ux-factory-step]');
if (step) {
scrollToNode(stageTarget(root, step.dataset.uxFactoryStep));
return;
}
const next = event.target.closest('[data-ux-factory-next]');
if (next) {
const target = next.dataset.uxFactoryNext;
if (target === 'submit') {
scrollToNode(stageTarget(root, 'review'));
root.querySelector('#image-factory-submit')?.focus({ preventScroll: true });
} else {
scrollToNode(stageTarget(root, target));
}
return;
}
const filter = event.target.closest('[data-ux-factory-job-filter]');
if (filter) {
root.dataset.uxFactoryJobFilter = filter.dataset.uxFactoryJobFilter;
try { localStorage.setItem(JOB_FILTER_KEY, root.dataset.uxFactoryJobFilter); } catch {}
applyJobFilter(root);
}
});
}
function observeFactory(root) {
const observer = new MutationObserver(() => window.requestAnimationFrame(() => updateWorkflow(root)));
const targets = [
root.querySelector('#image-factory-selection-count'),
root.querySelector('#image-factory-source-grid'),
root.querySelector('#image-factory-profile-grid'),
root.querySelector('#image-factory-summary'),
root.querySelector('#image-factory-jobs'),
].filter(Boolean);
targets.forEach(target => observer.observe(target, { childList: true, subtree: true, characterData: true, attributes: true, attributeFilter: ['class', 'checked', 'aria-pressed'] }));
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
}
function initializeImageFactoryUx() {
const root = document.getElementById(ROOT_ID);
if (!root || root.dataset.uxImageFactoryReady === 'true') return;
createWorkflow(root);
restructureConfiguration(root);
createMonitor(root);
createJobControls(root);
bindActions(root);
observeFactory(root);
root.dataset.uxImageFactoryReady = 'true';
root.classList.add('ux-image-factory-ready');
updateWorkflow(root);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeImageFactoryUx, { once: true });
} else {
initializeImageFactoryUx();
}
})();