Files
vendoo/public/app.js
Masterluke77andGitHub 6f48827f4d Vendoo 1.41.2 – Git Ignore Boundary & Module Tracking Hotfix (#18)
* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix

* fix: track native source modules with root-anchored runtime ignores
2026-07-09 17:09:00 +02:00

9761 lines
546 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const VENDOO_UI_BUILD = '1.41.2';
const state = {
photos: [],
platform: '',
aiProvider: 'claude',
aiModel: '',
language: 'de',
variants: 1,
currentListing: null,
allVariants: null,
platforms: [],
providers: [],
templates: [],
editingTemplateId: null,
historyPage: 1,
historyPageSize: 10,
publishPage: 1,
publishPageSize: 10,
aiModelPhotos: [],
aiModelGenerations: [],
aiPromptScope: 'generator',
aiPromptDrafts: {
generator: { customPrompt: '', preserveLogos: true },
detail: { customPrompt: '', preserveLogos: true },
},
fluxStudioCurrent: null,
fluxStudioHistory: [],
fluxHistoryPage: 1,
fluxHistoryPageSize: 5,
fluxHistoryPagination: { page: 1, total_pages: 1, total: 0 },
fluxStudioBusy: false,
fluxJobs: [],
fluxJobSummary: null,
fluxRuntime: null,
fluxStudioWatchers: new Map(),
fluxJobExpanded: new Set(),
fluxJobManuallyToggled: new Set(),
fluxJobCenterCollapsed: false,
fluxJobArchiveMode: false,
fluxJobPage: 1,
fluxJobPageSize: 10,
fluxJobPagination: { page: 1, total_pages: 1, total: 0 },
fluxJobSelected: new Set(),
mediaItems: [],
mediaSelected: new Set(),
mediaPage: 1,
mediaPageSize: 24,
mediaPagination: { page: 1, total_pages: 1, total: 0 },
mediaSummary: null,
mediaView: 'grid',
mediaCurrent: null,
imageFactoryProfiles: [],
imageFactorySources: [],
imageFactorySelectedSources: new Set(),
imageFactorySelectedProfiles: new Set(),
imageFactoryJobs: [],
imageFactoryJobPage: 1,
imageFactoryJobPageSize: 10,
imageFactoryJobPagination: { page: 1, total_pages: 1, total: 0 },
imageFactorySummary: null,
imageFactoryExpanded: new Set(),
qualityItems: [],
qualityPage: 1,
qualityPageSize: 10,
qualityPagination: { page: 1, total_pages: 1, total: 0 },
qualitySummary: null,
qualityCurrent: null,
qualityIssueFilter: 'all',
qualityAiDraft: null,
};
const editors = {};
const htmlSyncState = { generatorLock: false, detailLock: false };
let detailEditorState = null;
const listingEditLock = { listingId: null, token: null, lock: null, timer: null };
const mobileUploadState = { session: null, pollTimer: null, target: null, imported: new Set() };
const QUILL_TOOLBAR = [
[{ header: [1, 2, 3, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ color: [] }, { background: [] }],
[{ list: 'ordered' }, { list: 'bullet' }],
[{ align: [] }],
['blockquote', 'link'],
['clean'],
];
function initQuill(container, placeholder) {
if (typeof Quill === 'undefined') return null;
return new Quill(container, {
theme: 'snow',
placeholder: placeholder || '',
modules: { toolbar: QUILL_TOOLBAR },
});
}
const RICH_ALLOWED_TAGS = new Set([
'P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'OL', 'UL', 'LI',
'BLOCKQUOTE', 'A', 'H1', 'H2', 'H3', 'SPAN', 'DIV', 'SUB', 'SUP', 'CODE', 'PRE',
]);
const RICH_DANGEROUS_TAGS = new Set([
'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA',
'SELECT', 'OPTION', 'SVG', 'MATH', 'VIDEO', 'AUDIO', 'LINK', 'META',
]);
function sanitizeStyleClient(style) {
return String(style || '').split(';').map(part => part.trim()).filter(Boolean).map(part => {
const index = part.indexOf(':');
if (index < 1) return '';
const name = part.slice(0, index).trim().toLowerCase();
const value = part.slice(index + 1).trim();
if (!['color', 'background-color', 'text-align'].includes(name)) return '';
if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return '';
if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) return '';
if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) return '';
return `${name}:${value}`;
}).filter(Boolean).join(';');
}
function sanitizeRichHtmlClient(input) {
const doc = new DOMParser().parseFromString(`<body>${String(input || '')}</body>`, 'text/html');
const elements = [...doc.body.querySelectorAll('*')].reverse();
for (const element of elements) {
if (RICH_DANGEROUS_TAGS.has(element.tagName)) {
element.remove();
continue;
}
if (!RICH_ALLOWED_TAGS.has(element.tagName)) {
element.replaceWith(...element.childNodes);
continue;
}
for (const attr of [...element.attributes]) {
const name = attr.name.toLowerCase();
const value = attr.value;
let keep = false;
if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:|tel:|#|\/)/i.test(value.trim())) keep = true;
if (element.tagName === 'A' && name === 'target' && ['_blank', '_self'].includes(value)) keep = true;
if (element.tagName === 'A' && name === 'rel') { element.setAttribute('rel', 'noopener noreferrer'); keep = true; }
if (name === 'style') {
const safeStyle = sanitizeStyleClient(value);
if (safeStyle) { element.setAttribute('style', safeStyle); keep = true; }
}
if (name === 'class' && String(value).split(/\s+/).every(cls => /^ql-[a-z0-9-]+$/i.test(cls))) keep = true;
if (!keep) element.removeAttribute(attr.name);
}
if (element.tagName === 'A' && element.getAttribute('target') === '_blank') element.setAttribute('rel', 'noopener noreferrer');
}
return doc.body.innerHTML.trim();
}
function plainTextToRichHtml(text) {
const value = String(text || '').trim();
if (!value) return '';
return value.split(/\n{2,}/).map(paragraph => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`).join('');
}
function richHtmlToText(html) {
const container = document.createElement('div');
container.innerHTML = sanitizeRichHtmlClient(html);
container.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
container.querySelectorAll('p,div,h1,h2,h3,li,blockquote,pre').forEach(node => node.append('\n'));
return (container.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
}
function getEditorHtml(editor) {
if (!editor) return '';
const html = sanitizeRichHtmlClient(editor.root.innerHTML);
return /^(<p><br><\/p>|<p><\/p>)$/i.test(html) ? '' : html;
}
function setEditorHtml(editor, html) {
if (!editor) return;
const safe = sanitizeRichHtmlClient(html);
editor.setText('');
if (safe) editor.clipboard.dangerouslyPasteHTML(safe);
}
function setSandboxPreview(frame, html) {
if (!frame) return;
frame.srcdoc = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html{background:#fff}body{margin:0;padding:18px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#25231f;font-size:14px;line-height:1.55;overflow-wrap:anywhere}table{max-width:100%}a{color:#d93616}</style></head><body>${String(html || '')}</body></html>`;
}
function setHtmlSyncStatus(id, text, tone = '') {
const el = document.getElementById(id);
if (!el) return;
el.textContent = text;
el.dataset.tone = tone;
}
const clientDiagnostics = { incidents: [], lastSystemReport: null, online: true, healthTimer: null };
function recordClientIncident(type, message, details = {}) {
const incident = {
at: new Date().toISOString(),
type: String(type || 'client'),
message: String(message || 'Unbekannter Fehler').slice(0, 500),
view: document.querySelector('.tab-content.active')?.id || 'unknown',
build: VENDOO_UI_BUILD,
...details,
};
clientDiagnostics.incidents.unshift(incident);
clientDiagnostics.incidents = clientDiagnostics.incidents.slice(0, 30);
try { localStorage.setItem('vendoo_client_incidents', JSON.stringify(clientDiagnostics.incidents)); } catch {}
return incident;
}
function setConnectionState(online, detail = '') {
clientDiagnostics.online = !!online;
const banner = document.getElementById('connection-banner');
const title = document.getElementById('connection-banner-title');
const description = document.getElementById('connection-banner-detail');
if (!banner) return;
banner.classList.toggle('hidden', online);
banner.classList.toggle('is-online', online);
if (title) title.textContent = online ? 'Verbindung wiederhergestellt' : 'Vendoo ist nicht erreichbar';
if (description) description.textContent = detail || (online ? 'Die Anwendung ist wieder verbunden.' : 'Verbindung wird automatisch erneut geprüft.');
}
async function checkVendooHealth({ quiet = true } = {}) {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), 3500);
try {
const response = await fetch(`/healthz?t=${Date.now()}`, { cache: 'no-store', signal: controller.signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const result = await response.json();
const wasOffline = !clientDiagnostics.online;
setConnectionState(true, `Server erreichbar · Version ${result.version || VENDOO_UI_BUILD}`);
if (wasOffline && !quiet) toast('Verbindung zu Vendoo wiederhergestellt.');
return true;
} catch (error) {
setConnectionState(false, error?.name === 'AbortError' ? 'Zeitüberschreitung beim Serverzugriff.' : 'Serververbindung unterbrochen. Automatische Wiederholung läuft.');
return false;
} finally {
window.clearTimeout(timer);
}
}
function setupGlobalResilience() {
try {
const stored = JSON.parse(localStorage.getItem('vendoo_client_incidents') || '[]');
if (Array.isArray(stored)) clientDiagnostics.incidents = stored.slice(0, 30);
} catch {}
window.addEventListener('error', event => {
recordClientIncident('javascript', event.message, { file: event.filename || '', line: event.lineno || 0, column: event.colno || 0 });
});
window.addEventListener('unhandledrejection', event => {
const reason = event.reason;
recordClientIncident('promise', reason?.message || String(reason || 'Unbehandelte Promise-Ablehnung'));
});
window.addEventListener('offline', () => setConnectionState(false, 'Der Browser meldet keine Netzwerkverbindung.'));
window.addEventListener('online', () => checkVendooHealth({ quiet: false }));
document.getElementById('connection-retry-btn')?.addEventListener('click', () => checkVendooHealth({ quiet: false }));
checkVendooHealth();
clientDiagnostics.healthTimer = window.setInterval(() => checkVendooHealth(), 30000);
window.addEventListener('beforeunload', () => window.clearInterval(clientDiagnostics.healthTimer), { once: true });
}
function applyResponsiveContract() {
const main = document.querySelector('.main-wrapper');
if (!main) return;
const width = Math.round(main.getBoundingClientRect().width);
const bucket = width < 560 ? 'phone' : width < 820 ? 'narrow' : width < 1120 ? 'compact' : width < 1480 ? 'standard' : 'wide';
main.dataset.layoutWidth = bucket;
main.style.setProperty('--workspace-width', `${width}px`);
document.querySelectorAll('.tab-content').forEach(section => {
section.dataset.layoutWidth = bucket;
});
}
function setupResponsiveContracts() {
applyResponsiveContract();
const main = document.querySelector('.main-wrapper');
if (main && 'ResizeObserver' in window) {
const observer = new ResizeObserver(() => window.requestAnimationFrame(applyResponsiveContract));
observer.observe(main);
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
} else {
window.addEventListener('resize', debounce(applyResponsiveContract, 100));
}
}
async function init() {
document.documentElement.dataset.vendooBuild = VENDOO_UI_BUILD;
console.info(`[Vendoo] UI-Build ${VENDOO_UI_BUILD}`);
setupGlobalResilience();
setupResponsiveContracts();
// Auth check — redirect to login if not authenticated or setup needed
try {
const meCheck = await fetch('/api/me', { credentials: 'same-origin' });
if (meCheck.status === 401) { window.location.href = '/login.html'; return; }
const meData = await meCheck.json();
if (meData.setupMode) { window.location.href = '/login.html'; return; }
} catch {}
const [platforms, settings, providers, templates] = await Promise.all([
api('GET', '/api/platforms'),
api('GET', '/api/settings'),
api('GET', '/api/providers'),
api('GET', '/api/templates'),
]);
state.platforms = platforms;
state.providers = providers;
state.templates = templates;
state.platform = settings.default_platform || 'vinted';
state.aiProvider = settings.default_ai || 'claude';
state.language = settings.default_language || 'de';
renderPlatformButtons();
renderProviderButtons();
populateSettingsForm(settings, platforms, providers);
populateTemplateSelects(platforms);
renderTemplateList();
updateTemplateDropdown();
document.getElementById('language-select').value = state.language;
editors.result = initQuill('#result-description-editor', 'Beschreibung wird hier angezeigt...');
editors.tplNotes = initQuill('#tpl-notes-editor', 'Verkäufernotizen...');
setupGeneratorHtmlSync();
setupHtmlViewSwitches();
setupMobileUpload();
addQuillTooltips();
setupTabs();
setupAnalyticsDashboard();
setupTodayFlow();
setupUpload();
setupImageTools();
setupGenerate();
setupGeneratorStudio();
setupAiPromptModal();
setupFluxStudio();
setupMediaLibrary();
setupImageFactory();
setupQualityCenter();
setupExtensions();
setupCopy();
setupHistory();
setupPublish();
setupTrash();
setupTemplates();
setupFeeCalculator();
setupSettings();
setupDarkMode();
setupPhotoEditor();
setupBackup();
setupNotifications();
setupUserMenu();
setupAdmin();
loadAnalyticsDashboard();
updateTrashBadge();
}
// --- API ---
let _csrfToken = '';
function getCsrf() {
if (_csrfToken) return _csrfToken;
const m = document.cookie.match(/csrf_token=([^;]+)/);
return m ? m[1] : '';
}
async function api(method, url, body, options = {}) {
const verb = String(method || 'GET').toUpperCase();
const attempts = verb === 'GET' && options.retry !== false ? 2 : 1;
const timeoutMs = Number(options.timeoutMs || (verb === 'GET' ? 20000 : 90000));
const requestId = globalThis.crypto?.randomUUID?.() || `vd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
let lastError;
for (let attempt = 1; attempt <= attempts; attempt += 1) {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
const opts = { method: verb, headers: { 'X-Vendoo-Request-ID': requestId, ...(options.headers || {}) }, credentials: 'same-origin', signal: controller.signal };
if (body !== undefined && body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
if (!['GET', 'HEAD'].includes(verb)) opts.headers['X-CSRF-Token'] = getCsrf();
try {
const res = await fetch(url, opts);
if (res.status === 401) { window.location.href = '/login.html'; return; }
const contentType = res.headers.get('content-type') || '';
const payload = contentType.includes('application/json')
? await res.json().catch(() => ({}))
: await res.text().catch(() => '');
if (!res.ok) {
const error = new Error(payload?.error || payload?.message || (typeof payload === 'string' && payload) || res.statusText || 'Fehler');
error.status = res.status;
error.requestId = requestId;
if (attempt < attempts && res.status >= 500) { lastError = error; continue; }
throw error;
}
if (!clientDiagnostics.online) setConnectionState(true, 'Serververbindung wiederhergestellt.');
return payload;
} catch (error) {
lastError = error?.name === 'AbortError' ? new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`) : error;
lastError.requestId = requestId;
const retryable = attempt < attempts && (error?.name === 'AbortError' || error instanceof TypeError || Number(error?.status || 0) >= 500);
if (retryable) { await new Promise(resolve => window.setTimeout(resolve, 250)); continue; }
recordClientIncident('api', lastError.message, { method: verb, url, requestId, status: lastError.status || 0 });
if (error instanceof TypeError || error?.name === 'AbortError') setConnectionState(false, `${lastError.message} · Anfrage ${requestId.slice(0, 8)}`);
throw lastError;
} finally {
window.clearTimeout(timer);
}
}
throw lastError || new Error('Anfrage fehlgeschlagen');
}
function aiModelStatusClass(tone = 'neutral') {
return `ai-model-status ${tone ? `is-${tone}` : ''}`.trim();
}
function setAiModelStatus(elementId, message, tone = 'neutral') {
const element = document.getElementById(elementId);
if (!element) return;
element.textContent = message;
element.className = aiModelStatusClass(tone);
}
function setAiModelJobProgress(elementId, job) {
const element = document.getElementById(elementId);
if (!element) return;
const progress = Math.max(0, Math.min(100, Number(job?.progress || 0)));
const message = job?.status === 'failed'
? (job.error_message || 'AI-Bildjob fehlgeschlagen')
: (job?.progress_message || formatAiJobStatus(job));
const tone = job?.status === 'failed' ? 'danger' : job?.status === 'completed' ? 'success' : 'working';
element.className = `${aiModelStatusClass(tone)} ai-job-progress-card`;
element.innerHTML = `
<div class="ai-job-progress-head"><strong>${escapeHtml(message)}</strong><span>${progress}%</span></div>
<div class="ai-job-progress-track"><div style="width:${progress}%"></div></div>
<small>${job?.status === 'queued' ? 'Warteschlange' : job?.status === 'running' ? 'Lokale Verarbeitung läuft' : job?.status === 'completed' ? 'Abgeschlossen' : 'Aktion beendet'}</small>
`;
}
function getGeneratorAiModelPayload() {
return {
photos: [...state.photos],
platform: state.platform,
title: document.getElementById('result-title')?.value || state.currentListing?.title || '',
category: metaCategoryPicker?.getValue?.() || state.currentListing?.category || '',
brand: document.getElementById('meta-brand')?.value || state.currentListing?.brand || '',
color: document.getElementById('meta-color')?.value || state.currentListing?.color || '',
size: document.getElementById('meta-size')?.value || state.currentListing?.size || '',
condition: document.getElementById('meta-condition')?.value || state.currentListing?.condition || '',
seller_notes: document.getElementById('generator-seller-notes')?.value || state.currentListing?.seller_notes || '',
mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay',
preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed',
variants: parseInt(document.getElementById('generator-ai-model-variants')?.value || '1', 10),
};
}
function buildAiModelCardHtml(items, scope) {
const prefix = scope === 'detail' ? 'detail' : 'generator';
return `
<div class="ai-model-grid-head">
<strong>${items.length} sichere Bildvariante${items.length === 1 ? '' : 'n'}</strong>
<button type="button" class="small-btn" data-ai-model-add-all="${scope}">Alle zu Fotos hinzufügen</button>
</div>
<div class="ai-model-grid-cards">
${items.map((photo, index) => `
<article class="ai-model-shot-card">
<img src="${escapeHtml(photoUrl(photo.image_path || photo.filename))}" alt="Produktbild-Variante ${index + 1}" loading="lazy">
<div class="ai-model-shot-copy">
<strong>Variante ${photo.variant_index || index + 1}</strong>
<span>${escapeHtml((photo.mode || 'flatlay').replace('_', ' ').toUpperCase())} · ${escapeHtml((photo.preset || 'mixed').toUpperCase())}</span>
</div>
<button type="button" class="small-btn" data-ai-model-add-one="${escapeHtml(photo.image_path || photo.filename)}" data-ai-model-scope="${prefix}">Zu Fotos hinzufügen</button>
</article>`).join('')}
</div>`;
}
function renderAiModelGrid(scope, items = []) {
const grid = document.getElementById(scope === 'detail' ? 'detail-ai-model-grid' : 'generator-ai-model-grid');
if (!grid) return;
if (!items.length) {
grid.innerHTML = '';
grid.classList.add('hidden');
return;
}
grid.innerHTML = buildAiModelCardHtml(items, scope);
grid.classList.remove('hidden');
const addOne = filename => {
if (scope === 'detail') {
if (!detailEditorState) return;
if (!detailEditorState.photos.includes(filename)) detailEditorState.photos.push(filename);
detailEditorState.activePhotoIndex = detailEditorState.photos.length - 1;
renderDetailPhotos();
} else {
if (!state.photos.includes(filename)) state.photos.push(filename);
renderPhotoPreviews();
updateGenerateButton();
}
toast('AI-Model-Foto hinzugefügt');
};
grid.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => addOne(button.dataset.aiModelAddOne)));
grid.querySelector('[data-ai-model-add-all]')?.addEventListener('click', () => {
items.forEach(photo => addOne(photo.image_path || photo.filename));
toast('Alle AI-Model-Fotos hinzugefügt');
});
}
async function pollAiModelJob(jobId, onProgress) {
const started = Date.now();
while (Date.now() - started < 360000) {
const job = await api('GET', `/api/ai-model-photos/jobs/${jobId}`);
if (typeof onProgress === 'function') onProgress(job);
if (job.status === 'completed' || job.status === 'failed') return job;
await new Promise(resolve => setTimeout(resolve, 1800));
}
throw new Error('Zeitüberschreitung bei der AI-Bildgenerierung');
}
function formatAiJobStatus(job) {
if (!job) return 'AI-Bildjob wird vorbereitet …';
if (job.status === 'queued') return job.progress_message || 'AI-Bildjob steht in der Warteschlange …';
if (job.status === 'running') return job.progress_message || `AI-Bilder werden erzeugt … ${job.progress || 1}%`;
if (job.status === 'failed') return job.error_message || 'AI-Bildjob fehlgeschlagen';
return job.progress_message || 'AI-Bildjob abgeschlossen';
}
function getAiPromptDraft(scope = 'generator') {
if (!state.aiPromptDrafts[scope]) state.aiPromptDrafts[scope] = { customPrompt: '', preserveLogos: true };
return state.aiPromptDrafts[scope];
}
function getAiPromptContext(scope = 'generator') {
if (scope === 'detail') {
return {
mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay',
preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed',
variants: document.getElementById('detail-ai-model-variants')?.value || '1',
};
}
return {
mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay',
preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed',
variants: document.getElementById('generator-ai-model-variants')?.value || '1',
};
}
function updateAiPromptSummary(scope = 'generator') {
const ctx = getAiPromptContext(scope);
const modeLabel = ctx.mode === 'ghost_mannequin' ? 'Ghost Mannequin' : 'Flatlay';
const lookLabel = ctx.preset === 'mixed' ? 'Mix' : ctx.preset === 'studio' ? 'Studio' : ctx.preset === 'indoor' ? 'Indoor' : 'Outdoor';
document.getElementById('ai-prompt-mode-chip').textContent = `Modus: ${modeLabel}`;
document.getElementById('ai-prompt-look-chip').textContent = `Look: ${lookLabel}`;
document.getElementById('ai-prompt-variants-chip').textContent = `Varianten: ${ctx.variants}`;
const logoCheck = document.getElementById('ai-prompt-preserve-logos');
if (logoCheck) {
logoCheck.disabled = false;
}
}
function closeAiPromptModal() {
const modal = document.getElementById('ai-prompt-modal');
modal?.classList.add('hidden');
modal?.setAttribute('aria-hidden', 'true');
document.body.classList.remove('ai-prompt-open');
}
function syncAiPromptDraftFromModal() {
const scope = state.aiPromptScope || 'generator';
const draft = getAiPromptDraft(scope);
draft.customPrompt = document.getElementById('ai-prompt-textarea')?.value || '';
draft.preserveLogos = document.getElementById('ai-prompt-preserve-logos')?.checked !== false;
}
function openAiPromptModal(scope = 'generator') {
const modal = document.getElementById('ai-prompt-modal');
if (!modal) return;
state.aiPromptScope = scope;
const draft = getAiPromptDraft(scope);
document.getElementById('ai-prompt-scope-copy').textContent = scope === 'detail' ? 'Zusatzanweisung für das aktuelle Listing' : 'Zusatzanweisung für den Generator';
document.getElementById('ai-prompt-textarea').value = draft.customPrompt || '';
document.getElementById('ai-prompt-preserve-logos').checked = draft.preserveLogos !== false;
updateAiPromptSummary(scope);
modal.classList.remove('hidden');
modal.setAttribute('aria-hidden', 'false');
document.body.classList.add('ai-prompt-open');
setTimeout(() => document.getElementById('ai-prompt-textarea')?.focus(), 30);
}
function setupAiPromptModal() {
const modal = document.getElementById('ai-prompt-modal');
if (!modal) return;
modal.querySelectorAll('[data-ai-prompt-close]').forEach(btn => btn.addEventListener('click', closeAiPromptModal));
document.getElementById('ai-prompt-textarea')?.addEventListener('input', syncAiPromptDraftFromModal);
document.getElementById('ai-prompt-preserve-logos')?.addEventListener('change', syncAiPromptDraftFromModal);
document.getElementById('ai-prompt-reset-btn')?.addEventListener('click', () => {
const draft = getAiPromptDraft(state.aiPromptScope || 'generator');
draft.customPrompt = '';
draft.preserveLogos = true;
document.getElementById('ai-prompt-textarea').value = '';
document.getElementById('ai-prompt-preserve-logos').checked = true;
});
document.getElementById('ai-prompt-run-btn')?.addEventListener('click', async () => {
syncAiPromptDraftFromModal();
const scope = state.aiPromptScope || 'generator';
closeAiPromptModal();
if (scope === 'detail') await runDetailAiModelPhotos();
else await runGeneratorAiModelPhotos();
});
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !modal.classList.contains('hidden')) closeAiPromptModal();
});
}
const FLUX_PROMPT_TEMPLATES = {
product: 'Professionelle Produktfotografie, klares Hauptmotiv, neutraler Studiohintergrund, weiches Licht, realistische Materialien, hochwertige kommerzielle Bildsprache',
social: 'Modernes Social-Media-Motiv, klarer visueller Fokus, starke aber ausgewogene Komposition, hochwertige Beleuchtung, sofort verständlich',
editorial: 'Premium Editorial-Fotografie, anspruchsvolle Magazinkomposition, kontrolliertes Licht, elegante Farbwelt, hochwertige realistische Details',
background: 'Minimalistischer heller Studiohintergrund, weiche natürliche Schatten, dezente Tiefe, sauber, hochwertig, ohne Text und ohne Personen',
lifestyle: 'Authentische hochwertige Lifestyle-Szene, natürliches Tageslicht, glaubwürdige Umgebung, harmonische Komposition, fotorealistisch',
banner: 'Breites hochwertiges Werbemotiv, klarer freier Bereich für spätere Texte, kontrollierte Beleuchtung, moderne kommerzielle Gestaltung',
};
const FLUX_PROFILE_PRESETS = {
fast: { format: '768x768', steps: '4' },
balanced: { format: '1024x1024', steps: '4' },
quality: { format: '1024x1024', steps: '6' },
custom: null,
};
const FLUX_TERMINAL_JOB_STATUSES = new Set(['completed', 'completed_with_errors', 'failed', 'cancelled']);
const FLUX_ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']);
function analyzeFluxPrompt(value = '') {
const text = String(value || '').trim();
if (!text) return { level: 'empty', copy: 'Beschreibe zuerst Motiv, Umgebung, Licht und Perspektive.' };
const words = text.split(/\s+/).filter(Boolean).length;
const signals = [
/licht|lighting|beleuchtung/i,
/hintergrund|background|umgebung|scene|studio/i,
/kamera|camera|perspektive|perspective|close-up|nahaufnahme|wide shot/i,
/farbe|color|material|texture|oberfläche/i,
].filter(pattern => pattern.test(text)).length;
if (words < 8) return { level: 'weak', copy: 'Noch sehr kurz. Ergänze Motiv, Umgebung, Licht und Bildausschnitt.' };
if (words < 18 || signals < 2) return { level: 'good', copy: 'Gute Basis. Der Prompt-Assistent kann Komposition und technische Bildsprache ergänzen.' };
return { level: 'strong', copy: 'Starker Prompt mit ausreichend visueller Führung.' };
}
function refreshFluxPromptQuality() {
const input = document.getElementById('flux-studio-prompt');
const box = document.getElementById('flux-prompt-quality');
const copy = document.getElementById('flux-prompt-quality-copy');
if (!box || !copy) return;
const result = analyzeFluxPrompt(input?.value || '');
box.dataset.level = result.level;
copy.textContent = result.copy;
}
function optimizeFluxPromptLocally(value = '') {
let text = String(value || '').trim().replace(/\s+/g, ' ');
if (!text) return text;
const additions = [];
if (!/licht|lighting|beleuchtung/i.test(text)) additions.push('weiches gerichtetes Licht mit realistischen Schatten');
if (!/kamera|camera|perspektive|perspective|aufnahme|shot/i.test(text)) additions.push('klarer professioneller Bildausschnitt auf Augenhöhe');
if (!/hintergrund|background|umgebung|scene|studio/i.test(text)) additions.push('ruhige, zum Motiv passende Umgebung');
if (!/detail|material|texture|oberfläche/i.test(text)) additions.push('realistische Materialien und feine Oberflächendetails');
if (additions.length) text = `${text.replace(/[,. ]+$/, '')}, ${additions.join(', ')}`;
return text;
}
function applyFluxProfile(profile, { persist = true } = {}) {
const normalized = FLUX_PROFILE_PRESETS[profile] ? profile : 'custom';
const hidden = document.getElementById('flux-studio-profile');
if (hidden) hidden.value = normalized;
document.querySelectorAll('[data-flux-profile]').forEach(button => button.classList.toggle('active', button.dataset.fluxProfile === normalized));
const preset = FLUX_PROFILE_PRESETS[normalized];
if (preset) {
const format = document.getElementById('flux-studio-format');
const steps = document.getElementById('flux-studio-steps');
if (format) format.value = preset.format;
if (steps) steps.value = preset.steps;
}
if (persist) { try { localStorage.setItem('vendoo-flux-profile', normalized); } catch {} }
}
function setFluxVariantCount(value, { persist = true } = {}) {
const normalized = [1, 2, 4].includes(Number(value)) ? Number(value) : 1;
const input = document.getElementById('flux-studio-variants');
if (input) input.value = String(normalized);
document.querySelectorAll('[data-flux-variants]').forEach(button => button.classList.toggle('active', Number(button.dataset.fluxVariants) === normalized));
if (persist) { try { localStorage.setItem('vendoo-flux-variants', String(normalized)); } catch {} }
}
function resetFluxStudioWorkspace({ clearSavedDefaults = false, announce = false } = {}) {
state.fluxStudioCurrent = null;
renderFluxStudioPreview(null);
const variants = document.getElementById('flux-studio-variant-results');
if (variants) { variants.innerHTML = ''; variants.classList.add('hidden'); }
const prompt = document.getElementById('flux-studio-prompt');
if (prompt) { prompt.value = ''; prompt.dispatchEvent(new Event('input')); }
const mode = document.getElementById('flux-studio-prompt-mode');
if (mode) mode.value = 'precise';
const style = document.getElementById('flux-studio-style');
if (style) style.value = 'photorealistic';
const seed = document.getElementById('flux-studio-seed');
if (seed) seed.value = '42';
const lock = document.getElementById('flux-studio-seed-lock');
if (lock) lock.checked = false;
applyFluxProfile('balanced', { persist: !clearSavedDefaults });
setFluxVariantCount(1, { persist: !clearSavedDefaults });
if (clearSavedDefaults) {
try {
localStorage.removeItem('vendoo-flux-profile');
localStorage.removeItem('vendoo-flux-variants');
} catch {}
}
setFluxStudioStatus('Bereit. Noch keine Auswahl.', '');
setFluxStudioProgress('', false);
renderFluxStudioHistory();
if (announce) toast('FLUX Studio wurde auf einen leeren Arbeitsbereich zurückgesetzt. Verlauf und Bilder bleiben erhalten.');
}
function parseFluxFormat() {
const value = document.getElementById('flux-studio-format')?.value || '768x768';
const [width, height] = value.split('x').map(Number);
return { width: width || 768, height: height || 768 };
}
function setFluxStudioStatus(message, tone = '') {
const status = document.getElementById('flux-studio-status');
if (!status) return;
status.textContent = message;
status.dataset.tone = tone;
}
function setFluxStudioProgress(label = 'Vorbereitung', visible = true, stateName = 'active') {
const shell = document.getElementById('flux-studio-progress');
const value = document.getElementById('flux-studio-progress-value');
const text = document.getElementById('flux-studio-progress-label');
shell?.classList.toggle('hidden', !visible);
if (shell) shell.dataset.state = stateName;
if (value) value.textContent = stateName === 'done' ? 'Fertig' : stateName === 'error' ? 'Fehler' : stateName === 'cancelled' ? 'Abgebrochen' : 'Aktive Phase';
if (text) text.textContent = label || 'FLUX arbeitet …';
}
function formatFluxDate(value) {
if (!value) return '';
const normalized = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
}
function formatFluxDuration(ms) {
const seconds = Math.max(0, Math.round(Number(ms || 0) / 1000));
if (seconds < 60) return `${seconds} s`;
return `${Math.floor(seconds / 60)} min ${seconds % 60} s`;
}
function fluxStatusLabel(status) {
return ({
queued: 'Wartend', running: 'Läuft', cancelling: 'Wird abgebrochen', completed: 'Erfolgreich',
completed_with_errors: 'Teilweise fertig', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen',
})[status] || status || 'Unbekannt';
}
async function refreshFluxStudioEngineStatus() {
const chip = document.getElementById('flux-studio-engine-chip');
try {
const status = await api('GET', '/api/local-image/status');
const ready = !!status.reachable;
if (chip) {
chip.textContent = status.engine_label || (ready ? 'Aktiv und bereit' : 'Nicht bereit');
chip.className = `settings-status-chip ${ready ? 'is-ready' : ['stopped','starting'].includes(status.engine_state) ? 'is-warning' : 'is-disabled'}`;
}
document.getElementById('flux-studio-generate')?.toggleAttribute('disabled', !ready || state.fluxStudioBusy);
return status;
} catch (error) {
if (chip) { chip.textContent = 'Status nicht verfügbar'; chip.className = 'settings-status-chip is-disabled'; }
return null;
}
}
async function waitForFluxEngineReady({ statusElement = null, timeoutMs = 190000 } = {}) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
const status = await refreshFluxStudioEngineStatus();
if (status?.reachable) {
if (statusElement) statusElement.textContent = 'Lokaler FLUX/ComfyUI-Server ist aktiv und bereit.';
return status;
}
const runtime = status?.runtime_state || {};
const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
const detail = runtime.message || status?.message || 'ComfyUI wird initialisiert.';
if (statusElement) statusElement.textContent = `FLUX startet seit ${elapsed}s · ${detail}`;
setFluxStudioStatus(`FLUX startet seit ${elapsed}s · ${detail}`, 'working');
if (runtime.status === 'failed' || status?.engine_state === 'failed') {
const tail = [...(runtime.stderr_tail || []), ...(runtime.stdout_tail || [])].filter(Boolean).slice(-6).join(' | ');
throw new Error(runtime.error || tail || runtime.message || 'ComfyUI wurde während des Starts beendet.');
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
throw new Error('ComfyUI war nach 190 Sekunden noch nicht erreichbar. Prüfe die Runtime-Protokolle in Vendoo/logs.');
}
function fluxVariantToImage(job, variant) {
if (!variant?.image_path) return null;
return {
id: variant.image_id,
job_id: job.id,
variant_index: variant.variant_index,
prompt: job.prompt,
final_prompt: job.final_prompt,
image_path: variant.image_path,
public_url: variant.public_url,
width: job.width,
height: job.height,
seed: variant.seed,
steps: job.steps,
style: job.style,
profile: job.profile,
prompt_mode: job.prompt_mode,
duration_ms: variant.duration_ms,
favorite: variant.favorite,
parameters: {
width: job.width, height: job.height, steps: job.steps, style: job.style, profile: job.profile,
prompt_mode: job.prompt_mode, variants: job.variants_count, seed_lock: job.seed_lock,
},
};
}
function renderFluxVariantResults(job) {
const container = document.getElementById('flux-studio-variant-results');
if (!container) return;
const completed = (job?.variants || []).filter(variant => variant.image_path);
container.classList.toggle('hidden', !completed.length);
if (!completed.length) { container.innerHTML = ''; return; }
container.innerHTML = completed.map(variant => {
const item = fluxVariantToImage(job, variant);
const active = Number(state.fluxStudioCurrent?.id) === Number(item.id);
return `<button type="button" class="flux-variant-tile ${active ? 'active' : ''}" data-flux-variant-image="${escapeHtml(String(item.id))}">
<img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="Variante ${variant.variant_index}">
<span>Variante ${variant.variant_index}</span><small>Seed ${variant.seed}</small>
</button>`;
}).join('');
container.querySelectorAll('[data-flux-variant-image]').forEach(button => button.addEventListener('click', () => {
const variant = completed.find(entry => String(entry.image_id) === button.dataset.fluxVariantImage);
const item = fluxVariantToImage(job, variant);
if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); }
}));
}
function renderFluxStudioPreview(item) {
state.fluxStudioCurrent = item || null;
const image = document.getElementById('flux-studio-preview-image');
const empty = document.getElementById('flux-studio-preview-empty');
const actions = document.getElementById('flux-studio-preview-actions');
const meta = document.getElementById('flux-studio-preview-meta');
const download = document.getElementById('flux-studio-download');
const favorite = document.getElementById('flux-studio-favorite');
if (!item?.image_path) {
image?.classList.add('hidden');
empty?.classList.remove('hidden');
actions?.classList.add('hidden');
if (meta) meta.textContent = 'Noch kein Bild';
return;
}
if (image) {
image.src = item.public_url || photoUrl(item.image_path);
image.alt = item.prompt ? `FLUX: ${item.prompt}` : 'FLUX-Vorschau';
image.classList.remove('hidden');
}
empty?.classList.add('hidden');
actions?.classList.remove('hidden');
if (meta) meta.textContent = `${item.width || ''} × ${item.height || ''} · Seed ${item.seed ?? ''} · ${item.steps || ''} Schritte${item.duration_ms ? ` · ${formatFluxDuration(item.duration_ms)}` : ''}`;
if (download) {
download.href = item.public_url || photoUrl(item.image_path);
download.download = `vendoo-flux-${item.id || Date.now()}.${String(item.image_path).split('.').pop() || 'png'}`;
}
if (favorite) favorite.textContent = item.favorite ? '★ Favorit' : '☆ Favorit';
}
function renderFluxStudioHistory() {
const container = document.getElementById('flux-studio-history');
if (!container) return;
if (!state.fluxStudioHistory.length) {
container.innerHTML = '<div class="empty-state">Noch keine passenden FLUX-Bilder gespeichert.</div>';
return;
}
container.innerHTML = state.fluxStudioHistory.map(item => `<article class="flux-history-card ${state.fluxStudioCurrent?.id === item.id ? 'active' : ''}">
<button type="button" class="flux-history-image" data-flux-select="${item.id}"><img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="${escapeHtml(item.prompt || 'FLUX-Bild')}"><span>${item.favorite ? '★' : ''}</span></button>
<div><strong title="${escapeHtml(item.prompt || '')}">${escapeHtml(item.prompt || 'FLUX-Bild')}</strong><small>${item.width} × ${item.height} · Seed ${item.seed ?? ''} · ${formatFluxDate(item.created_at)}</small></div>
<div class="flux-history-actions"><button type="button" class="small-btn" data-flux-favorite="${item.id}">${item.favorite ? '★' : '☆'}</button><button type="button" class="small-btn" data-flux-similar="${item.id}">Ähnlich</button><button type="button" class="small-btn" data-flux-edit="${item.id}">Bearbeiten</button><button type="button" class="small-btn danger" data-flux-delete="${item.id}">Löschen</button></div>
</article>`).join('');
container.querySelectorAll('[data-flux-select]').forEach(button => button.addEventListener('click', () => {
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSelect);
if (item) { renderFluxStudioPreview(item); renderFluxStudioHistory(); }
}));
container.querySelectorAll('[data-flux-favorite]').forEach(button => button.addEventListener('click', async () => {
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxFavorite);
if (item) await toggleFluxFavorite(item);
}));
container.querySelectorAll('[data-flux-similar]').forEach(button => button.addEventListener('click', () => {
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSimilar);
if (item) createSimilarFluxJob(item);
}));
container.querySelectorAll('[data-flux-edit]').forEach(button => button.addEventListener('click', () => {
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxEdit);
if (item) { renderFluxStudioPreview(item); openPhotoEditor(0, 'flux'); }
}));
container.querySelectorAll('[data-flux-delete]').forEach(button => button.addEventListener('click', async () => {
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxDelete);
if (item) await deleteFluxStudioImage(item);
}));
}
function renderFluxHistoryPagination() {
const pagination = state.fluxHistoryPagination || {};
const page = Number(pagination.page || 1);
const pages = Number(pagination.total_pages || 1);
const total = Number(pagination.total || 0);
document.getElementById('flux-history-page-info')?.replaceChildren(document.createTextNode(`Seite ${page} von ${pages} · ${total} Bilder`));
const prev = document.getElementById('flux-history-prev');
const next = document.getElementById('flux-history-next');
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = page >= pages;
}
async function loadFluxStudioHistory({ silent = false } = {}) {
const container = document.getElementById('flux-studio-history');
if (!silent && container && !state.fluxStudioHistory.length) container.innerHTML = '<div class="empty-state">FLUX-Bibliothek wird geladen …</div>';
try {
const favorites = document.getElementById('flux-studio-favorites-only')?.checked ? '1' : '0';
const result = await api('GET', `/api/flux-studio/images?page=${state.fluxHistoryPage}&page_size=${state.fluxHistoryPageSize}&favorites=${favorites}`);
state.fluxStudioHistory = result.items || [];
state.fluxHistoryPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxStudioHistory.length };
state.fluxHistoryPage = Number(state.fluxHistoryPagination.page || 1);
if (state.fluxStudioCurrent) {
const refreshed = state.fluxStudioHistory.find(item => item.id === state.fluxStudioCurrent.id);
if (refreshed) renderFluxStudioPreview(refreshed);
else renderFluxStudioPreview(null);
}
renderFluxStudioHistory();
renderFluxHistoryPagination();
} catch (error) {
if (container) container.innerHTML = `<div class="empty-state">Bibliothek konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
async function toggleFluxFavorite(item = state.fluxStudioCurrent) {
if (!item?.id) return;
try {
const updated = await api('PATCH', `/api/flux-studio/images/${item.id}/favorite`, { favorite: !item.favorite });
state.fluxStudioHistory = state.fluxStudioHistory.map(entry => entry.id === updated.id ? updated : entry);
if (state.fluxStudioCurrent?.id === updated.id) renderFluxStudioPreview(updated);
await loadFluxStudioHistory({ silent: true });
toast(updated.favorite ? 'Als Favorit gespeichert.' : 'Favorit entfernt.');
} catch (error) { toast(`Favorit konnte nicht geändert werden: ${error.message}`); }
}
async function deleteFluxStudioImage(item = state.fluxStudioCurrent) {
if (!item?.id) return;
if (!window.confirm('Dieses FLUX-Bild wirklich dauerhaft löschen?')) return;
try {
await api('DELETE', `/api/flux-studio/images/${item.id}`);
if (state.fluxStudioCurrent?.id === item.id) renderFluxStudioPreview(null);
await loadFluxStudioHistory({ silent: true });
toast('FLUX-Bild gelöscht.');
} catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); }
}
function getFluxStudioPayload() {
const { width, height } = parseFluxFormat();
return {
prompt: document.getElementById('flux-studio-prompt')?.value.trim() || '',
width,
height,
seed: Number(document.getElementById('flux-studio-seed')?.value || 42),
steps: Number(document.getElementById('flux-studio-steps')?.value || 4),
style: document.getElementById('flux-studio-style')?.value || 'none',
profile: document.getElementById('flux-studio-profile')?.value || 'balanced',
prompt_mode: document.getElementById('flux-studio-prompt-mode')?.value || 'precise',
assistant: true,
variants: Number(document.getElementById('flux-studio-variants')?.value || 1),
seed_lock: !!document.getElementById('flux-studio-seed-lock')?.checked,
};
}
function applyFluxJobToForm(jobOrImage) {
const item = jobOrImage || {};
const params = item.parameters || {};
const prompt = document.getElementById('flux-studio-prompt');
if (prompt && item.prompt) { prompt.value = item.prompt; prompt.dispatchEvent(new Event('input')); }
const format = `${item.width || params.width || 768}x${item.height || params.height || 768}`;
const formatSelect = document.getElementById('flux-studio-format');
if (formatSelect && [...formatSelect.options].some(option => option.value === format)) formatSelect.value = format;
const steps = document.getElementById('flux-studio-steps');
if (steps && (item.steps || params.steps)) steps.value = String(item.steps || params.steps);
const style = document.getElementById('flux-studio-style'); if (style) style.value = item.style || params.style || 'none';
const mode = document.getElementById('flux-studio-prompt-mode'); if (mode) mode.value = item.prompt_mode || params.prompt_mode || 'precise';
const seed = document.getElementById('flux-studio-seed'); if (seed && Number.isFinite(Number(item.seed ?? item.seed_base))) seed.value = String(item.seed ?? item.seed_base);
const lock = document.getElementById('flux-studio-seed-lock'); if (lock) lock.checked = !!(item.seed_lock ?? params.seed_lock);
setFluxVariantCount(item.variants_count || params.variants || 1);
applyFluxProfile(item.profile || params.profile || 'custom');
}
async function watchFluxJob(jobId) {
if (!jobId || state.fluxStudioWatchers.has(jobId)) return;
state.fluxStudioWatchers.set(jobId, true);
try {
const deadline = Date.now() + 12 * 60 * 1000;
while (Date.now() < deadline) {
const job = await api('GET', `/api/flux-prompt/jobs/${encodeURIComponent(jobId)}`);
const activeVariant = (job.variants || []).find(variant => variant.status === 'running') || (job.variants || []).find(variant => variant.status === 'queued');
const label = activeVariant?.message || job.message || fluxStatusLabel(job.status);
const stateName = job.status === 'failed' ? 'error' : job.status === 'cancelled' ? 'cancelled' : FLUX_TERMINAL_JOB_STATUSES.has(job.status) ? 'done' : 'active';
setFluxStudioProgress(label, true, stateName);
setFluxStudioStatus(label, stateName === 'error' ? 'error' : stateName === 'done' ? 'success' : 'working');
const images = (job.variants || []).filter(variant => variant.image_path);
if (images.length) {
const selected = fluxVariantToImage(job, images[0]);
if (selected) renderFluxStudioPreview(selected);
renderFluxVariantResults(job);
}
await loadFluxJobs({ silent: true });
if (FLUX_TERMINAL_JOB_STATUSES.has(job.status)) {
await loadFluxStudioHistory();
await refreshFluxRuntimeInfo({ silent: true });
if (job.status === 'failed') toast(`FLUX-Auftrag fehlgeschlagen: ${job.error || 'Unbekannter Fehler'}`);
else if (job.status === 'cancelled') toast('FLUX-Auftrag wurde abgebrochen.');
else toast(job.status === 'completed_with_errors' ? 'FLUX-Auftrag teilweise abgeschlossen.' : 'FLUX-Auftrag abgeschlossen.');
return job;
}
await new Promise(resolve => setTimeout(resolve, 1400));
}
setFluxStudioStatus('Der Auftrag läuft weiter im Job-Center.', 'working');
} catch (error) {
setFluxStudioStatus(error.message, 'error');
} finally {
state.fluxStudioWatchers.delete(jobId);
}
}
async function runFluxStudioGeneration(payloadOverride = null) {
if (state.fluxStudioBusy) return;
const payload = payloadOverride || getFluxStudioPayload();
if (String(payload.prompt || '').trim().length < 3) return toast('Bitte einen aussagekräftigen Prompt eingeben.');
const button = document.getElementById('flux-studio-generate');
state.fluxStudioBusy = true;
if (button) button.disabled = true;
setFluxStudioProgress('Auftrag wird dauerhaft gespeichert …', true, 'active');
setFluxStudioStatus('FLUX-Auftrag wird erstellt …', 'working');
try {
const queued = await api('POST', '/api/flux-prompt/jobs', payload);
setFluxStudioStatus(`${queued.variants_count} Variante${queued.variants_count === 1 ? '' : 'n'} wurde${queued.variants_count === 1 ? '' : 'n'} eingereiht.`, 'success');
await loadFluxJobs({ silent: true });
watchFluxJob(queued.id);
return queued;
} catch (error) {
setFluxStudioStatus(error.message, 'error');
setFluxStudioProgress('Auftrag konnte nicht erstellt werden.', true, 'error');
toast(`FLUX-Fehler: ${error.message}`);
return null;
} finally {
state.fluxStudioBusy = false;
if (button) button.disabled = false;
refreshFluxStudioEngineStatus();
}
}
async function createSimilarFluxJob(item = state.fluxStudioCurrent) {
if (!item?.prompt) return;
applyFluxJobToForm(item);
const seed = Math.floor(Math.random() * 2147483647);
const seedInput = document.getElementById('flux-studio-seed'); if (seedInput) seedInput.value = String(seed);
toast('Prompt und Parameter wurden übernommen; der neue Basis-Seed erzeugt ähnliche Varianten.');
await runFluxStudioGeneration();
}
function renderFluxJobSummary() {
const container = document.getElementById('flux-job-summary');
if (!container) return;
const summary = state.fluxJobSummary || {};
const jobs = summary.jobs || {};
const variants = summary.variants || {};
document.getElementById('flux-job-archive-count')?.replaceChildren(document.createTextNode(String(summary.archived_total || 0)));
container.innerHTML = state.fluxJobArchiveMode
? `<div><span>Archivierte Aufträge</span><strong>${summary.archived_total || 0}</strong></div>
<div><span>Auf dieser Seite</span><strong>${state.fluxJobs.length}</strong></div>
<div><span>Seiten</span><strong>${state.fluxJobPagination.total_pages || 1}</strong></div>
<div><span>Bilder bleiben erhalten</span><strong>Ja</strong></div>`
: `<div><span>Aktive Worker</span><strong>${summary.active_workers || 0} / ${summary.max_parallel || 1}</strong></div>
<div><span>Wartende Varianten</span><strong>${variants.queued || 0}</strong></div>
<div><span>Laufende Varianten</span><strong>${variants.running || 0}</strong></div>
<div><span>Fertige Aufträge</span><strong>${(jobs.completed || 0) + (jobs.completed_with_errors || 0)}</strong></div>`;
}
function updateFluxJobToolbar() {
const count = state.fluxJobSelected.size;
const archiveMode = state.fluxJobArchiveMode;
document.getElementById('flux-job-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`));
const selectPage = document.getElementById('flux-job-select-page');
if (selectPage) {
const selectedOnPage = state.fluxJobs.filter(job => state.fluxJobSelected.has(job.id)).length;
selectPage.checked = state.fluxJobs.length > 0 && selectedOnPage === state.fluxJobs.length;
selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.fluxJobs.length;
}
const archive = document.getElementById('flux-job-archive-selected');
const restore = document.getElementById('flux-job-restore-selected');
const remove = document.getElementById('flux-job-delete-selected');
const removeAll = document.getElementById('flux-job-delete-all-archive');
archive?.classList.toggle('hidden', archiveMode);
restore?.classList.toggle('hidden', !archiveMode);
remove?.classList.toggle('hidden', !archiveMode);
removeAll?.classList.toggle('hidden', !archiveMode);
if (archive) archive.disabled = count === 0;
if (restore) restore.disabled = count === 0;
if (remove) remove.disabled = count === 0;
document.getElementById('flux-job-clear-queue')?.classList.toggle('hidden', archiveMode);
document.getElementById('flux-job-filter')?.classList.toggle('hidden', archiveMode);
document.getElementById('flux-job-view-active')?.classList.toggle('active', !archiveMode);
document.getElementById('flux-job-view-archive')?.classList.toggle('active', archiveMode);
}
function renderFluxJobPagination() {
const pagination = state.fluxJobPagination || {};
const page = Number(pagination.page || 1);
const pages = Number(pagination.total_pages || 1);
const total = Number(pagination.total || 0);
const info = document.getElementById('flux-job-page-info');
if (info) info.textContent = `Seite ${page} von ${pages} · ${total} Auftrag${total === 1 ? '' : 'e'}`;
const prev = document.getElementById('flux-job-prev');
const next = document.getElementById('flux-job-next');
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = page >= pages;
}
async function archiveFluxJobs(ids) {
if (!ids.length) return;
const result = await api('POST', '/api/flux-prompt/jobs/archive', { ids });
state.fluxJobSelected.clear();
toast(`${result.archived || 0} Auftrag/Aufträge archiviert${result.skipped ? ` · ${result.skipped} aktiv oder nicht archiviert` : ''}.`);
await loadFluxJobs();
}
async function restoreFluxJobs(ids) {
if (!ids.length) return;
const result = await api('POST', '/api/flux-prompt/jobs/restore', { ids });
state.fluxJobSelected.clear();
toast(`${result.restored || 0} Auftrag/Aufträge wiederhergestellt.`);
await loadFluxJobs();
}
async function deleteArchivedFluxJobs(ids = [], all = false) {
const message = all
? 'Das komplette FLUX-Auftragsarchiv endgültig löschen? Die erzeugten Bilder bleiben in der Bildergalerie erhalten.'
: `${ids.length} archivierte Auftrag/Aufträge endgültig löschen? Die erzeugten Bilder bleiben erhalten.`;
if (!window.confirm(message)) return;
const result = await api('DELETE', `/api/flux-prompt/jobs/archive${all ? '?all=1' : ''}`, { ids });
state.fluxJobSelected.clear();
toast(`${result.deleted || 0} archivierte Auftrag/Aufträge gelöscht.`);
await loadFluxJobs();
}
function renderFluxJobList() {
const container = document.getElementById('flux-job-list');
if (!container) return;
const jobs = state.fluxJobs;
if (!jobs.length) {
container.innerHTML = `<div class="empty-state">${state.fluxJobArchiveMode ? 'Das Auftragsarchiv ist leer.' : 'Keine Aufträge für diesen Filter.'}</div>`;
updateFluxJobToolbar();
return;
}
container.innerHTML = jobs.map(job => {
const variants = job.variants || [];
const expanded = state.fluxJobExpanded.has(job.id);
const selected = state.fluxJobSelected.has(job.id);
const thumbs = variants.map(variant => variant.image_path
? `<button type="button" class="flux-job-thumb" data-flux-job-image="${job.id}:${variant.variant_index}" title="Variante ${variant.variant_index} · Seed ${variant.seed}${variant.prompt_id ? ` · Prompt-ID ${escapeHtml(variant.prompt_id)}` : ''}"><img src="${escapeHtml(variant.public_url || photoUrl(variant.image_path))}" alt="Variante ${variant.variant_index}"><span>${variant.variant_index}</span><small>Seed ${variant.seed}</small></button>`
: `<div class="flux-job-thumb is-placeholder" title="${escapeHtml(variant.message || '')}"><span>${variant.variant_index}</span><small>${escapeHtml(fluxStatusLabel(variant.status))} · Seed ${variant.seed}</small></div>`).join('');
const ledger = variants.map(variant => `<div><b>V${variant.variant_index}</b><span>Seed ${variant.seed}</span><span>${escapeHtml(variant.phase || variant.status)}</span>${variant.prompt_id ? `<code title="${escapeHtml(variant.prompt_id)}">${escapeHtml(String(variant.prompt_id).slice(0, 12))}${String(variant.prompt_id).length > 12 ? '…' : ''}</code>` : '<code>Noch keine Prompt-ID</code>'}</div>`).join('');
const canCancel = FLUX_ACTIVE_JOB_STATUSES.has(job.status);
const canRetry = ['failed', 'cancelled', 'completed_with_errors'].includes(job.status) && !state.fluxJobArchiveMode;
const canArchive = !state.fluxJobArchiveMode && !canCancel;
return `<article class="flux-job-card${expanded ? ' is-expanded' : ' is-collapsed'}${selected ? ' is-selected' : ''}" data-status="${escapeHtml(job.status)}">
<div class="flux-job-card-header">
<label class="flux-job-card-select"><input type="checkbox" data-flux-job-select="${job.id}" ${selected ? 'checked' : ''} aria-label="Auftrag auswählen"></label>
<button type="button" class="flux-job-card-toggle" data-flux-job-toggle="${job.id}" aria-expanded="${expanded}">
<span class="flux-job-card-toggle-main"><span class="flux-job-status is-${escapeHtml(job.status)}">${escapeHtml(fluxStatusLabel(job.status))}</span><strong class="flux-job-prompt" title="${escapeHtml(job.prompt)}">${escapeHtml(job.prompt)}</strong></span>
<span class="flux-job-card-toggle-meta">${formatFluxDate(job.archived_at || job.created_at)}${job.elapsed_ms ? ` · ${formatFluxDuration(job.elapsed_ms)}` : ''}<i aria-hidden="true">⌄</i></span>
</button>
</div>
<div class="flux-job-card-body">
<div class="flux-job-card-main"><div class="flux-job-params"><span>${job.width} × ${job.height}</span><span>${job.steps} Schritte</span><span>${escapeHtml(job.style)}</span><span>${job.variants_count} Variante${job.variants_count === 1 ? '' : 'n'}</span><span>Seed ${job.seed_base}${job.seed_lock ? ' · Lock' : ''}</span></div><p>${escapeHtml(job.message || '')}${job.error ? `<br><b>${escapeHtml(job.error)}</b>` : ''}</p></div>
<div class="flux-job-variants">${thumbs}</div><div class="flux-job-variant-ledger">${ledger}</div>
<div class="flux-job-actions">
<button type="button" class="small-btn" data-flux-job-load="${job.id}">Parameter laden</button>
${canCancel ? `<button type="button" class="small-btn danger" data-flux-job-cancel="${job.id}">Abbrechen</button>` : ''}
${canRetry ? `<button type="button" class="small-btn" data-flux-job-retry="${job.id}">Wiederholen</button>` : ''}
${canArchive ? `<button type="button" class="small-btn" data-flux-job-archive="${job.id}">Archivieren</button>` : ''}
${state.fluxJobArchiveMode ? `<button type="button" class="small-btn" data-flux-job-restore="${job.id}">Wiederherstellen</button><button type="button" class="small-btn danger" data-flux-job-delete="${job.id}">Endgültig löschen</button>` : ''}
</div>
</div>
</article>`;
}).join('');
container.querySelectorAll('[data-flux-job-select]').forEach(input => input.addEventListener('change', () => {
if (input.checked) state.fluxJobSelected.add(input.dataset.fluxJobSelect); else state.fluxJobSelected.delete(input.dataset.fluxJobSelect);
input.closest('.flux-job-card')?.classList.toggle('is-selected', input.checked);
updateFluxJobToolbar();
}));
container.querySelectorAll('[data-flux-job-toggle]').forEach(button => button.addEventListener('click', () => {
const jobId = button.dataset.fluxJobToggle;
state.fluxJobManuallyToggled.add(jobId);
if (state.fluxJobExpanded.has(jobId)) state.fluxJobExpanded.delete(jobId); else state.fluxJobExpanded.add(jobId);
renderFluxJobList();
}));
container.querySelectorAll('[data-flux-job-load]').forEach(button => button.addEventListener('click', () => {
const job = state.fluxJobs.find(entry => entry.id === button.dataset.fluxJobLoad);
if (job) { applyFluxJobToForm(job); setFluxStudioStatus('Prompt und Parameter wurden aus dem Verlauf geladen.', 'success'); }
}));
container.querySelectorAll('[data-flux-job-cancel]').forEach(button => button.addEventListener('click', async () => {
try { await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobCancel}/cancel`, {}); toast('Abbruch wurde angefordert.'); await loadFluxJobs(); } catch (error) { toast(`Abbruch fehlgeschlagen: ${error.message}`); }
}));
container.querySelectorAll('[data-flux-job-retry]').forEach(button => button.addEventListener('click', async () => {
try { const job = await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobRetry}/retry`, {}); state.fluxJobExpanded.add(job.id); toast('Auftrag wurde erneut eingereiht.'); await loadFluxJobs(); watchFluxJob(job.id); } catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); }
}));
container.querySelectorAll('[data-flux-job-archive]').forEach(button => button.addEventListener('click', () => archiveFluxJobs([button.dataset.fluxJobArchive])));
container.querySelectorAll('[data-flux-job-restore]').forEach(button => button.addEventListener('click', () => restoreFluxJobs([button.dataset.fluxJobRestore])));
container.querySelectorAll('[data-flux-job-delete]').forEach(button => button.addEventListener('click', () => deleteArchivedFluxJobs([button.dataset.fluxJobDelete])));
container.querySelectorAll('[data-flux-job-image]').forEach(button => button.addEventListener('click', () => {
const [jobId, variantIndex] = button.dataset.fluxJobImage.split(':');
const job = state.fluxJobs.find(entry => entry.id === jobId);
const variant = job?.variants?.find(entry => String(entry.variant_index) === variantIndex);
const item = fluxVariantToImage(job, variant);
if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); }
}));
updateFluxJobToolbar();
}
async function loadFluxJobs({ silent = false } = {}) {
const container = document.getElementById('flux-job-list');
if (!silent && container && !state.fluxJobs.length) container.innerHTML = '<div class="empty-state">Job-Center wird geladen …</div>';
try {
const filter = state.fluxJobArchiveMode ? '' : (document.getElementById('flux-job-filter')?.value || '');
const params = new URLSearchParams({ page: String(state.fluxJobPage), page_size: String(state.fluxJobPageSize), archived: state.fluxJobArchiveMode ? '1' : '0' });
if (filter) params.set('status', filter);
const result = await api('GET', `/api/flux-prompt/jobs?${params}`);
state.fluxJobs = result.jobs || [];
state.fluxJobSummary = result.summary || null;
state.fluxJobPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxJobs.length };
state.fluxJobPage = Number(state.fluxJobPagination.page || 1);
state.fluxJobSelected = new Set([...state.fluxJobSelected].filter(id => state.fluxJobs.some(job => job.id === id)));
for (const job of state.fluxJobs) if (FLUX_ACTIVE_JOB_STATUSES.has(job.status) && !state.fluxJobManuallyToggled.has(job.id)) state.fluxJobExpanded.add(job.id);
renderFluxJobSummary(); renderFluxJobList(); renderFluxJobPagination(); updateFluxJobToolbar();
if (!state.fluxJobArchiveMode) for (const job of state.fluxJobs.filter(entry => FLUX_ACTIVE_JOB_STATUSES.has(entry.status))) watchFluxJob(job.id);
} catch (error) {
if (!silent && container) container.innerHTML = `<div class="empty-state">Job-Center konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
function bytesToGiB(value) {
const number = Number(value || 0);
return number > 0 ? `${(number / 1024 / 1024 / 1024).toFixed(1)} GB` : '';
}
function renderFluxRuntimeInfo() {
const container = document.getElementById('flux-runtime-summary');
if (!container) return;
const runtime = state.fluxRuntime;
if (!runtime) { container.innerHTML = '<div class="empty-state">Keine Systemdaten verfügbar.</div>'; return; }
const stats = runtime.system_stats || {};
const system = stats.system || stats;
const devices = Array.isArray(stats.devices) ? stats.devices : Array.isArray(system.devices) ? system.devices : [];
const gpu = devices[0] || {};
const queue = runtime.queue_summary || {};
const historyCount = runtime.history ? Object.keys(runtime.history).length : 0;
container.innerHTML = `
<div class="flux-runtime-grid">
<div><span>ComfyUI</span><strong>${runtime.reachable ? 'Erreichbar' : 'Nicht erreichbar'}</strong><small>${escapeHtml(runtime.url || '')}</small></div>
<div><span>ComfyUI-Queue</span><strong>${queue.running || 0} aktiv · ${queue.pending || 0} wartend</strong><small>${historyCount} Historieneinträge geladen</small></div>
<div><span>GPU</span><strong>${escapeHtml(gpu.name || gpu.type || 'Nicht gemeldet')}</strong><small>${gpu.vram_total ? `${bytesToGiB(gpu.vram_free)} frei von ${bytesToGiB(gpu.vram_total)}` : 'VRAM-Daten nicht gemeldet'}</small></div>
<div><span>System</span><strong>${escapeHtml(system.os || system.system || system.python_version || 'ComfyUI-System')}</strong><small>${escapeHtml(system.comfyui_version || system.version || '')}</small></div>
</div>`;
}
async function refreshFluxRuntimeInfo({ silent = false } = {}) {
const container = document.getElementById('flux-runtime-summary');
if (!silent && container) container.innerHTML = '<div class="empty-state">ComfyUI-Warteschlange, Historie und GPU-Daten werden geladen …</div>';
try {
state.fluxRuntime = await api('GET', '/api/flux-prompt/runtime');
renderFluxRuntimeInfo();
} catch (error) {
state.fluxRuntime = null;
if (container) container.innerHTML = `<div class="empty-state">Systemdaten nicht verfügbar: ${escapeHtml(error.message)}</div>`;
}
}
async function useFluxImageInGenerator() {
const item = state.fluxStudioCurrent;
if (!item?.id) return;
try {
const result = await api('POST', `/api/flux-studio/images/${item.id}/copy-to-generator`, {});
if (result.filename && !state.photos.includes(result.filename)) state.photos.push(result.filename);
renderPhotoPreview();
switchToTab('generator');
toast('Eine unabhängige Kopie wurde als Artikelfoto übernommen.');
} catch (error) { toast(`Übernahme fehlgeschlagen: ${error.message}`); }
}
function aiProviderDisplayName(provider = state.aiProvider) {
return state.providers.find(entry => entry.id === provider)?.name || ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || 'AI';
}
function openFluxPromptLab() {
const prompt = document.getElementById('flux-studio-prompt');
if (!prompt?.value.trim()) return toast('Bitte zuerst einen Prompt eingeben.');
const modal = document.getElementById('flux-prompt-lab');
document.getElementById('flux-prompt-lab-original').value = prompt.value.trim();
document.getElementById('flux-prompt-lab-result').value = '';
document.getElementById('flux-prompt-lab-notes').textContent = '';
document.getElementById('flux-prompt-lab-status').textContent = 'Bereit für lokale Strukturierung oder AI-Veredelung.';
document.getElementById('flux-prompt-lab-provider').textContent = `${aiProviderDisplayName()}${state.aiModel ? ` · ${state.aiModel}` : ' · Auto'}`;
modal?.classList.remove('hidden');
modal?.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
setTimeout(() => document.getElementById('flux-prompt-lab-original')?.focus(), 40);
}
function closeFluxPromptLab() {
const modal = document.getElementById('flux-prompt-lab');
modal?.classList.add('hidden');
modal?.setAttribute('aria-hidden', 'true');
if (document.getElementById('smart-copy-modal')?.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
function setupFluxPromptLab() {
document.querySelectorAll('[data-flux-prompt-lab-close]').forEach(button => button.addEventListener('click', closeFluxPromptLab));
document.getElementById('flux-prompt-lab-local')?.addEventListener('click', () => {
const original = document.getElementById('flux-prompt-lab-original')?.value || '';
const optimized = optimizeFluxPromptLocally(original);
if (!optimized) return toast('Bitte zuerst einen Prompt eingeben.');
document.getElementById('flux-prompt-lab-result').value = optimized;
document.getElementById('flux-prompt-lab-status').textContent = 'Lokal strukturiert · keine API-Anfrage.';
document.getElementById('flux-prompt-lab-notes').textContent = 'Motiv, Licht, Komposition und Qualitätsmerkmale wurden in eine klarere Reihenfolge gebracht.';
});
document.getElementById('flux-prompt-lab-ai')?.addEventListener('click', async event => {
const original = document.getElementById('flux-prompt-lab-original')?.value.trim() || '';
if (!original) return toast('Bitte zuerst einen Prompt eingeben.');
const button = event.currentTarget;
const status = document.getElementById('flux-prompt-lab-status');
button.disabled = true;
button.textContent = `${aiProviderDisplayName()} arbeitet …`;
status.textContent = `Prompt wird mit ${aiProviderDisplayName()} veredelt …`;
try {
const payload = {
prompt: original,
provider: state.aiProvider,
model: state.aiModel || undefined,
goal: document.getElementById('flux-prompt-lab-goal')?.value || 'product',
preserve_subject: document.getElementById('flux-prompt-lab-preserve')?.checked !== false,
avoid_text: document.getElementById('flux-prompt-lab-avoid-text')?.checked !== false,
};
const result = await api('POST', '/api/flux-prompt/improve', payload);
document.getElementById('flux-prompt-lab-result').value = result.improved_prompt || '';
document.getElementById('flux-prompt-lab-notes').textContent = result.notes || 'Prompt wurde für FLUX veredelt.';
status.textContent = `Fertig · ${aiProviderDisplayName(result.provider)}${result.model ? ` · ${result.model}` : ''}`;
} catch (error) {
status.textContent = `AI-Veredelung fehlgeschlagen: ${error.message}`;
toast(`Prompt Lab: ${error.message}`);
} finally {
button.disabled = false;
button.textContent = 'Mit AI veredeln';
}
});
document.getElementById('flux-prompt-lab-apply')?.addEventListener('click', () => {
const result = document.getElementById('flux-prompt-lab-result')?.value.trim();
if (!result) return toast('Erzeuge zuerst einen optimierten Prompt.');
const prompt = document.getElementById('flux-studio-prompt');
prompt.value = result.slice(0, 2000);
prompt.dispatchEvent(new Event('input', { bubbles: true }));
prompt.focus();
closeFluxPromptLab();
toast('Optimierter Prompt wurde übernommen.');
});
}
function setupFluxStudio() {
const prompt = document.getElementById('flux-studio-prompt');
if (!prompt) return;
const updateCount = () => { const count = document.getElementById('flux-studio-prompt-count'); if (count) count.textContent = String(prompt.value.length); };
prompt.addEventListener('input', () => { updateCount(); refreshFluxPromptQuality(); });
updateCount();
refreshFluxPromptQuality();
document.getElementById('flux-studio-optimize-prompt')?.addEventListener('click', openFluxPromptLab);
setupFluxPromptLab();
document.querySelectorAll('[data-flux-profile]').forEach(button => button.addEventListener('click', () => applyFluxProfile(button.dataset.fluxProfile)));
document.querySelectorAll('[data-flux-variants]').forEach(button => button.addEventListener('click', () => setFluxVariantCount(button.dataset.fluxVariants)));
document.getElementById('flux-studio-format')?.addEventListener('change', () => applyFluxProfile('custom'));
document.getElementById('flux-studio-steps')?.addEventListener('change', () => applyFluxProfile('custom'));
let savedFluxProfile = 'balanced'; try { savedFluxProfile = localStorage.getItem('vendoo-flux-profile') || savedFluxProfile; } catch {}
let savedFluxVariants = 1; try { savedFluxVariants = Number(localStorage.getItem('vendoo-flux-variants') || 1); } catch {}
applyFluxProfile(savedFluxProfile, { persist: false });
setFluxVariantCount(savedFluxVariants, { persist: false });
renderFluxStudioPreview(null);
const initialVariantResults = document.getElementById('flux-studio-variant-results');
if (initialVariantResults) { initialVariantResults.innerHTML = ''; initialVariantResults.classList.add('hidden'); }
document.querySelectorAll('[data-flux-template]').forEach(button => button.addEventListener('click', () => {
const template = FLUX_PROMPT_TEMPLATES[button.dataset.fluxTemplate];
if (!template) return;
prompt.value = prompt.value.trim() ? `${prompt.value.trim()}, ${template}` : template;
updateCount(); refreshFluxPromptQuality(); prompt.focus();
}));
document.getElementById('flux-studio-random-seed')?.addEventListener('click', () => {
document.getElementById('flux-studio-seed').value = String(Math.floor(Math.random() * 2147483647));
});
document.getElementById('flux-studio-generate')?.addEventListener('click', () => runFluxStudioGeneration());
document.getElementById('flux-studio-reset')?.addEventListener('click', () => {
if (state.fluxStudioBusy && !window.confirm('Ein FLUX-Auftrag läuft noch. Nur den sichtbaren Arbeitsbereich zurücksetzen? Der Auftrag läuft im Job-Center weiter.')) return;
resetFluxStudioWorkspace({ clearSavedDefaults: true, announce: true });
});
document.getElementById('flux-studio-refresh-engine')?.addEventListener('click', async () => { await refreshFluxStudioEngineStatus(); await refreshFluxRuntimeInfo(); });
document.getElementById('flux-runtime-refresh')?.addEventListener('click', () => refreshFluxRuntimeInfo());
document.getElementById('flux-job-refresh')?.addEventListener('click', () => loadFluxJobs());
document.getElementById('flux-job-filter')?.addEventListener('change', () => { state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); });
document.getElementById('flux-job-page-size')?.addEventListener('change', event => { state.fluxJobPageSize = Number(event.target.value) || 10; state.fluxJobPage = 1; loadFluxJobs(); });
document.getElementById('flux-job-view-active')?.addEventListener('click', () => { state.fluxJobArchiveMode = false; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); });
document.getElementById('flux-job-view-archive')?.addEventListener('click', () => { state.fluxJobArchiveMode = true; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); });
document.getElementById('flux-job-prev')?.addEventListener('click', () => { if (state.fluxJobPage > 1) { state.fluxJobPage -= 1; loadFluxJobs(); } });
document.getElementById('flux-job-next')?.addEventListener('click', () => { if (state.fluxJobPage < Number(state.fluxJobPagination.total_pages || 1)) { state.fluxJobPage += 1; loadFluxJobs(); } });
document.getElementById('flux-job-select-page')?.addEventListener('change', event => { for (const job of state.fluxJobs) { if (event.target.checked) state.fluxJobSelected.add(job.id); else state.fluxJobSelected.delete(job.id); } renderFluxJobList(); });
document.getElementById('flux-job-archive-selected')?.addEventListener('click', () => archiveFluxJobs([...state.fluxJobSelected]));
document.getElementById('flux-job-restore-selected')?.addEventListener('click', () => restoreFluxJobs([...state.fluxJobSelected]));
document.getElementById('flux-job-delete-selected')?.addEventListener('click', () => deleteArchivedFluxJobs([...state.fluxJobSelected]));
document.getElementById('flux-job-delete-all-archive')?.addEventListener('click', () => deleteArchivedFluxJobs([], true));
document.getElementById('flux-job-collapse-all')?.addEventListener('click', () => {
for (const job of state.fluxJobs) state.fluxJobManuallyToggled.add(job.id);
state.fluxJobExpanded.clear();
renderFluxJobList();
});
const jobCenterToggle = document.getElementById('flux-job-center-toggle');
const jobCenterBody = document.getElementById('flux-job-center-body');
const applyJobCenterState = () => {
jobCenterBody?.classList.toggle('hidden', state.fluxJobCenterCollapsed);
if (jobCenterToggle) {
jobCenterToggle.setAttribute('aria-expanded', String(!state.fluxJobCenterCollapsed));
jobCenterToggle.textContent = state.fluxJobCenterCollapsed ? 'Aufträge aufklappen' : 'Aufträge einklappen';
}
};
try { state.fluxJobCenterCollapsed = localStorage.getItem('vendoo-flux-job-center-collapsed') === '1'; } catch {}
applyJobCenterState();
jobCenterToggle?.addEventListener('click', () => {
state.fluxJobCenterCollapsed = !state.fluxJobCenterCollapsed;
try { localStorage.setItem('vendoo-flux-job-center-collapsed', state.fluxJobCenterCollapsed ? '1' : '0'); } catch {}
applyJobCenterState();
});
document.getElementById('flux-job-clear-queue')?.addEventListener('click', async () => {
if (!window.confirm('Alle noch wartenden FLUX-Varianten aus der Warteschlange entfernen? Laufende Varianten bleiben unberührt.')) return;
try { const result = await api('DELETE', '/api/flux-prompt/jobs/queue'); toast(`${result.cleared_variants || 0} wartende Variante(n) entfernt.`); await loadFluxJobs(); }
catch (error) { toast(`Warteschlange konnte nicht geleert werden: ${error.message}`); }
});
document.getElementById('flux-studio-refresh-history')?.addEventListener('click', () => loadFluxStudioHistory());
document.getElementById('flux-history-page-size')?.addEventListener('change', event => { state.fluxHistoryPageSize = Number(event.target.value) || 5; state.fluxHistoryPage = 1; loadFluxStudioHistory(); });
document.getElementById('flux-history-prev')?.addEventListener('click', () => { if (state.fluxHistoryPage > 1) { state.fluxHistoryPage -= 1; loadFluxStudioHistory(); } });
document.getElementById('flux-history-next')?.addEventListener('click', () => { if (state.fluxHistoryPage < Number(state.fluxHistoryPagination.total_pages || 1)) { state.fluxHistoryPage += 1; loadFluxStudioHistory(); } });
document.getElementById('flux-studio-favorites-only')?.addEventListener('change', () => { state.fluxHistoryPage = 1; loadFluxStudioHistory(); });
document.getElementById('flux-studio-edit')?.addEventListener('click', () => openPhotoEditor(0, 'flux'));
document.getElementById('flux-studio-similar')?.addEventListener('click', () => createSimilarFluxJob());
document.getElementById('flux-studio-favorite')?.addEventListener('click', () => toggleFluxFavorite());
document.getElementById('flux-studio-use-in-generator')?.addEventListener('click', useFluxImageInGenerator);
document.getElementById('flux-studio-delete')?.addEventListener('click', () => deleteFluxStudioImage());
document.getElementById('open-flux-studio-page-btn')?.addEventListener('click', () => switchToTab('flux-studio'));
document.getElementById('flux-studio-start-engine')?.addEventListener('click', async () => {
const button = document.getElementById('flux-studio-start-engine');
if (button) button.disabled = true;
try {
const profile = document.getElementById('setting-flux-runtime-profile')?.value || 'auto';
const result = await api('POST', '/api/local-image/start', { profile });
toast(result.message || 'FLUX wird gestartet.');
await waitForFluxEngineReady();
setFluxStudioStatus('FLUX ist aktiv und bereit.', 'success');
await refreshFluxRuntimeInfo();
toast('FLUX ist aktiv und bereit.');
} catch (error) {
setFluxStudioStatus(`FLUX-Start fehlgeschlagen: ${error.message}`, 'error');
toast(`FLUX-Start fehlgeschlagen: ${error.message}`);
} finally {
if (button) button.disabled = false;
}
});
document.getElementById('flux-studio-stop-engine')?.addEventListener('click', async () => {
try { const result = await api('POST', '/api/local-image/stop', {}); toast(result.message || 'FLUX wird gestoppt.'); setTimeout(refreshFluxStudioEngineStatus, 1200); }
catch (error) { toast(`FLUX-Stopp fehlgeschlagen: ${error.message}`); }
});
refreshFluxStudioEngineStatus();
loadFluxStudioHistory();
loadFluxJobs();
refreshFluxRuntimeInfo();
const poller = window.setInterval(() => {
const page = document.getElementById('flux-studio');
if (page?.classList.contains('active')) {
loadFluxJobs({ silent: true });
refreshFluxRuntimeInfo({ silent: true });
}
}, 5000);
window.addEventListener('beforeunload', () => window.clearInterval(poller), { once: true });
}
function updateAiModeControls(scope = 'generator') {
const promptButton = document.getElementById(`${scope}-ai-model-prompt-btn`);
if (promptButton) {
promptButton.disabled = false;
promptButton.title = 'Optionale Zusatzanweisung';
}
}
async function runGeneratorAiModelPhotos() {
if (!state.photos.length) return toast('Bitte zuerst Produktfotos hochladen');
const button = document.getElementById('generator-ai-model-btn');
button.disabled = true;
setAiModelStatus('generator-ai-model-status', 'Sicherheitsprüfung läuft …', 'working');
try {
const draft = getAiPromptDraft('generator');
const payload = { ...getGeneratorAiModelPayload(), custom_prompt: draft.customPrompt || '', preserve_logos: draft.preserveLogos ? '1' : '0' };
const precheck = await api('POST', '/api/ai-model-photos/precheck', payload);
if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert');
setAiModelStatus('generator-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working');
const queued = await api('POST', '/api/ai-model-photos/jobs', payload);
const finalJob = await pollAiModelJob(queued.job_id, (job) => {
setAiModelJobProgress('generator-ai-model-status', job);
});
if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.');
state.aiModelPhotos = finalJob.photos || [];
renderAiModelGrid('generator', state.aiModelPhotos);
const providerLabel = state.aiModelPhotos[0]?.provider_model || state.aiModelPhotos[0]?.provider || 'AI';
setAiModelStatus('generator-ai-model-status', `${state.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success');
} catch (err) {
setAiModelStatus('generator-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger');
toast(err.message || 'AI-Model-Fotos fehlgeschlagen');
} finally {
button.disabled = false;
}
}
async function runDetailAiModelPhotos() {
if (!detailEditorState?.photos?.length) return toast('Bitte zuerst Produktfotos hinzufügen');
const button = document.getElementById('detail-ai-model-btn');
if (button) button.disabled = true;
setAiModelStatus('detail-ai-model-status', 'Sicherheitsprüfung läuft …', 'working');
try {
const draft = getAiPromptDraft('detail');
const payload = {
listing_id: detailEditorState.listing.id,
photos: [...detailEditorState.photos],
platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform,
title: document.getElementById('detail-title')?.value || detailEditorState.listing.title || '',
category: detailEditorState.categoryPicker?.getValue?.() || detailEditorState.listing.category || '',
brand: document.getElementById('detail-brand')?.value || detailEditorState.listing.brand || '',
color: document.getElementById('detail-color')?.value || detailEditorState.listing.color || '',
size: document.getElementById('detail-size')?.value || detailEditorState.listing.size || '',
condition: document.getElementById('detail-condition')?.value || detailEditorState.listing.condition || '',
seller_notes: document.getElementById('detail-seller-notes')?.value || detailEditorState.listing.seller_notes || '',
mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay',
preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed',
variants: parseInt(document.getElementById('detail-ai-model-variants')?.value || '1', 10),
custom_prompt: draft.customPrompt || '',
preserve_logos: draft.preserveLogos ? '1' : '0',
};
const precheck = await api('POST', '/api/ai-model-photos/precheck', payload);
if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert');
setAiModelStatus('detail-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working');
const queued = await api('POST', '/api/ai-model-photos/jobs', payload);
const finalJob = await pollAiModelJob(queued.job_id, (job) => {
setAiModelJobProgress('detail-ai-model-status', job);
});
if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.');
detailEditorState.aiModelPhotos = finalJob.photos || [];
renderAiModelGrid('detail', detailEditorState.aiModelPhotos);
const providerLabel = detailEditorState.aiModelPhotos[0]?.provider_model || detailEditorState.aiModelPhotos[0]?.provider || 'AI';
setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success');
loadDetailAiModelGenerations(detailEditorState.listing.id);
} catch (err) {
setAiModelStatus('detail-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger');
toast(err.message || 'AI-Model-Fotos fehlgeschlagen');
} finally {
if (button) button.disabled = false;
}
}
async function loadDetailAiModelPhotos(listingId) {
try {
const items = await api('GET', `/api/listings/${listingId}/ai-model-photos`);
detailEditorState.aiModelPhotos = items || [];
renderAiModelGrid('detail', detailEditorState.aiModelPhotos);
if (detailEditorState.aiModelPhotos.length) {
setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} vorhandene AI-Bilder geladen.`, 'neutral');
}
} catch {}
}
function renderDetailAiModelGenerations(generations = []) {
const wrap = document.getElementById('detail-ai-model-history');
if (!wrap) return;
if (!generations.length) {
wrap.innerHTML = '<div class="ai-generation-empty">Noch keine AI-Bildhistorie vorhanden.</div>';
return;
}
wrap.innerHTML = generations.slice(0, 6).map(gen => `
<article class="ai-generation-card">
<header><strong>${escapeHtml((gen.mode || 'model').replace('_', ' '))}</strong><span>${escapeHtml((gen.preset || 'mixed').toUpperCase())} · ${gen.variants || 0} Bild/Bilder</span></header>
<div class="ai-generation-mini-grid">
${(gen.photos || []).slice(0, 3).map(photo => `<button type="button" class="ai-generation-thumb" data-ai-model-add-one="${escapeHtml(photo.image_path)}" data-ai-model-scope="detail"><img src="${escapeHtml(photoUrl(photo.image_path))}" alt="AI-Historie"></button>`).join('')}
</div>
</article>
`).join('');
wrap.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => {
const file = button.dataset.aiModelAddOne;
if (!detailEditorState.photos.includes(file)) detailEditorState.photos.push(file);
renderDetailPhotos();
toast('AI-Bild aus Historie hinzugefügt');
}));
}
async function loadDetailAiModelGenerations(listingId) {
try {
const items = await api('GET', `/api/listings/${listingId}/ai-model-generations`);
detailEditorState.aiModelGenerations = items || [];
renderDetailAiModelGenerations(detailEditorState.aiModelGenerations);
} catch {}
}
// --- Tab titles for topbar ---
const TAB_TITLES = {
today: 'Studio Flow',
dashboard: 'Dashboard',
generator: 'Neuer Artikel',
templates: 'Vorlagen',
history: 'Artikel',
'quality-center': 'Qualitätscenter',
publish: 'Publish',
trash: 'Papierkorb',
inventory: 'Lager',
fees: 'Gebühren',
admin: 'Admin / Benutzer & System',
'flux-studio': 'FLUX Studio',
'media-library': 'Bildergalerie',
'image-factory': 'Bildproduktion',
extensions: 'Extensions',
settings: 'Einstellungen',
};
const TAB_KICKERS = {
today: 'Arbeitsplatz',
dashboard: 'Analytics',
generator: 'Generator',
templates: 'Inhalte',
history: 'Übersicht',
'quality-center': 'Artikelprüfung',
publish: 'Control Center',
trash: 'Wiederherstellung',
inventory: 'Bestand',
fees: 'Kalkulation',
admin: 'Verwaltung',
'flux-studio': 'Bildgenerator',
'media-library': 'Medienverwaltung',
'image-factory': 'Batch Image Factory',
extensions: 'Browser-Integration',
settings: 'Konfiguration',
};
function setMobileSidebarOpen(open) {
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
const toggle = document.getElementById('sidebar-toggle');
if (!sidebar || !overlay) return;
sidebar.classList.toggle('open', !!open);
overlay.classList.toggle('show', !!open);
document.body.classList.toggle('mobile-nav-open', !!open);
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
sidebar.setAttribute('aria-hidden', (!open && window.matchMedia('(max-width: 768px)').matches) ? 'true' : 'false');
if (open) {
window.setTimeout(() => sidebar.querySelector('.nav-item.active, .nav-item')?.focus(), 60);
} else {
toggle?.focus({ preventScroll: true });
}
}
function closeMobileSidebar({ restoreFocus = false } = {}) {
const toggle = document.getElementById('sidebar-toggle');
const sidebar = document.getElementById('sidebar');
const overlay = document.getElementById('sidebar-overlay');
sidebar?.classList.remove('open');
overlay?.classList.remove('show');
document.body.classList.remove('mobile-nav-open');
toggle?.setAttribute('aria-expanded', 'false');
if (sidebar && window.matchMedia('(max-width: 768px)').matches) sidebar.setAttribute('aria-hidden', 'true');
if (restoreFocus) toggle?.focus({ preventScroll: true });
}
function switchToTab(tabId, adminTab) {
if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true });
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
const section = document.getElementById(tabId);
if (section) section.classList.add('active');
window.requestAnimationFrame(applyResponsiveContract);
// Highlight the correct sidebar item
const selector = adminTab
? `.nav-item[data-tab="${tabId}"][data-admin-tab="${adminTab}"]`
: `.nav-item[data-tab="${tabId}"]:not([data-admin-tab])`;
const activeBtn = document.querySelector(selector)
|| document.querySelector(`.nav-item[data-tab="${tabId}"]`);
if (activeBtn) activeBtn.classList.add('active');
// Update topbar title
const titleEl = document.getElementById('topbar-title');
if (titleEl) titleEl.textContent = TAB_TITLES[tabId] || tabId;
const kickerEl = document.getElementById('topbar-kicker');
if (kickerEl) kickerEl.textContent = TAB_KICKERS[tabId] || '';
// Switch admin sub-tab if specified
if (tabId === 'admin' && adminTab) {
switchAdminTab(adminTab);
if (adminTab === 'users') loadAdminUsers();
if (adminTab === 'roles') loadRoleMatrix();
if (adminTab === 'sessions') loadAdminSessions();
if (adminTab === 'smtp') loadSmtpSettings();
if (adminTab === 'system') { loadSystemInfo(); loadOperationsCenter(); }
if (adminTab === 'modules') globalThis.VendooModuleManager?.load?.();
if (adminTab === 'security') loadSecurityCenter();
if (adminTab === 'audit') loadAdminAudit();
}
// Load data for the tab
if (tabId === 'today') loadToday();
if (tabId === 'dashboard') loadAnalyticsDashboard();
if (tabId === 'history') { setHistoryDetailMode(false); loadHistory(); }
if (tabId === 'quality-center') loadQualityCenter();
if (tabId === 'publish') loadPublishList();
if (tabId === 'trash') loadTrash();
if (tabId === 'templates') renderTemplateList();
if (tabId === 'inventory') loadInventory();
if (tabId === 'fees') updateFeeCalculator();
if (tabId === 'flux-studio') { refreshFluxStudioEngineStatus(); loadFluxStudioHistory(); loadFluxJobs({ silent: true }); }
if (tabId === 'media-library') loadMediaLibrary();
if (tabId === 'image-factory') loadImageFactory();
if (tabId === 'extensions') loadExtensions();
// Close mobile sidebar
closeMobileSidebar();
}
// --- Tabs ---
function setupTabs() {
document.querySelectorAll('.nav-item').forEach(btn => {
btn.addEventListener('click', () => {
switchToTab(btn.dataset.tab, btn.dataset.adminTab);
});
});
// Mobile sidebar toggle
document.getElementById('sidebar-toggle')?.addEventListener('click', event => {
event.preventDefault();
event.stopPropagation();
const open = !document.getElementById('sidebar')?.classList.contains('open');
setMobileSidebarOpen(open);
});
document.getElementById('sidebar-close')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true }));
document.getElementById('sidebar-overlay')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true }));
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && document.getElementById('sidebar')?.classList.contains('open')) {
closeMobileSidebar({ restoreFocus: true });
}
});
window.addEventListener('resize', debounce(() => {
const mobile = window.matchMedia('(max-width: 768px)').matches;
const sidebar = document.getElementById('sidebar');
if (!mobile) {
closeMobileSidebar();
sidebar?.setAttribute('aria-hidden', 'false');
} else if (!sidebar?.classList.contains('open')) {
sidebar?.setAttribute('aria-hidden', 'true');
}
}, 120));
if (window.matchMedia('(max-width: 768px)').matches) document.getElementById('sidebar')?.setAttribute('aria-hidden', 'true');
// Topbar "Neuer Artikel" button
document.getElementById('topbar-new-btn')?.addEventListener('click', () => {
switchToTab('generator');
});
// Topbar search button — focus search in history
document.getElementById('topbar-search-btn')?.addEventListener('click', () => {
switchToTab('history');
setTimeout(() => document.getElementById('search-input')?.focus(), 100);
});
document.addEventListener('keydown', event => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
switchToTab('history');
setTimeout(() => document.getElementById('search-input')?.focus(), 100);
}
});
document.addEventListener('click', event => {
const target = event.target.closest('[data-tab-target]');
if (target) switchToTab(target.dataset.tabTarget);
});
}
// --- Platforms ---
function renderPlatformButtons() {
const c = document.getElementById('platform-buttons');
c.innerHTML = '';
for (const p of state.platforms) {
const btn = document.createElement('button');
btn.className = 'toggle-btn' + (p.id === state.platform ? ' active' : '');
btn.textContent = p.name;
btn.addEventListener('click', () => {
c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.platform = p.id;
updateGeneratorReadiness();
refreshGeneratorPlatformPreview();
if (!document.getElementById('meta-section').classList.contains('hidden') && metaCategoryPicker) {
const currentVal = metaCategoryPicker.getValue();
metaCategoryPicker = createCategoryPicker('meta-category-picker', p.id, null);
if (currentVal) {
const name = currentVal.split(' > ').pop();
api('GET', `/api/categories/${p.id}/match?q=${encodeURIComponent(name)}`).then(matches => {
if (matches.length && matches[0].score >= 50) metaCategoryPicker.setValue(matches[0].pathStr);
}).catch(() => {});
}
}
});
c.appendChild(btn);
}
}
// --- Providers ---
function renderProviderButtons() {
const c = document.getElementById('provider-buttons');
c.innerHTML = '';
for (const p of state.providers) {
const btn = document.createElement('button');
btn.className = 'toggle-btn' + (p.id === state.aiProvider ? ' active' : '');
btn.textContent = p.name;
btn.addEventListener('click', () => {
c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
state.aiProvider = p.id;
state.aiModel = '';
updateModelDropdown();
});
c.appendChild(btn);
}
updateModelDropdown();
}
function updateModelDropdown() {
const wrap = document.getElementById('model-select-wrap');
const sel = document.getElementById('model-select');
const prov = state.providers.find(p => p.id === state.aiProvider);
if (prov?.hasModels && prov.models?.length) {
const isOpenRouter = prov.id === 'openrouter';
const options = isOpenRouter
? `<option value="">Auto (Gemini zuerst, dann Free)</option>` + prov.models.map(m => `<option value="${m.id}">${m.name}</option>`).join('')
: prov.models.map(m => `<option value="${m.id}">${m.name}${m.free ? ' *' : ''}</option>`).join('');
sel.innerHTML = options;
state.aiModel = isOpenRouter ? '' : prov.models[0].id;
sel.onchange = () => { state.aiModel = sel.value; };
wrap.classList.remove('hidden');
} else {
state.aiModel = '';
wrap.classList.add('hidden');
}
}
// --- Upload ---
function setupUpload() {
const dz = document.getElementById('dropzone');
const fi = document.getElementById('file-input');
dz.addEventListener('click', () => fi.click());
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
fi.addEventListener('change', () => { handleFiles(fi.files); fi.value = ''; });
enablePhotoDragSort();
}
async function handleFiles(files) {
const available = Math.max(0, 10 - state.photos.length);
const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
if (!available) { toast('Maximal 10 Fotos pro Listing'); return; }
const accepted = imageFiles.slice(0, available);
if (imageFiles.length > available) toast(`Es wurden nur ${available} weitere Fotos übernommen`);
const fd = new FormData();
fd.append('platform', state.platform || 'allgemein');
for (const file of accepted) fd.append('photos', file);
if (!fd.has('photos')) return;
try {
const response = await fetch(`/api/upload?platform=${encodeURIComponent(state.platform || 'allgemein')}`, {
method: 'POST',
body: fd,
headers: { 'X-CSRF-Token': getCsrf() },
credentials: 'same-origin',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status})`);
state.photos.push(...(data.filenames || []));
renderPhotoPreviews();
updateGenerateButton();
} catch (err) { toast('Upload fehlgeschlagen: ' + err.message); }
}
function renderPhotoPreviews() {
renderPhotoPreview();
}
function renderPhotoPreview() {
const c = document.getElementById('photo-preview');
c.innerHTML = '';
for (let i = 0; i < state.photos.length; i++) {
const div = document.createElement('div');
div.className = 'thumb';
div.draggable = true;
div.dataset.index = String(i + 1);
const safeUrl = photoUrl(state.photos[i]);
div.innerHTML = `<img src="${safeUrl}" alt="Foto ${i + 1}"><button class="remove-photo" aria-label="Foto entfernen">&times;</button><button class="edit-photo" title="Im Advanced Editor bearbeiten" aria-label="Im Advanced Editor bearbeiten">✎</button>`;
div.querySelector('img').addEventListener('click', e => {
e.stopPropagation();
if (i > 0) {
const selected = state.photos.splice(i, 1)[0];
state.photos.unshift(selected);
renderPhotoPreview();
toast('Coverfoto geändert');
}
});
div.querySelector('.remove-photo').addEventListener('click', e => {
e.stopPropagation();
state.photos.splice(i, 1);
renderPhotoPreview();
updateGenerateButton();
});
div.querySelector('.edit-photo').addEventListener('click', e => {
e.stopPropagation();
openPhotoEditor(i);
});
c.appendChild(div);
}
document.getElementById('image-tools').classList.toggle('hidden', state.photos.length === 0);
updateGeneratorPhotoStage();
}
function updateGenerateButton() {
const ready = state.photos.length > 0;
document.getElementById('generate-btn').disabled = !ready;
if (!ready) {
state.aiModelPhotos = [];
renderAiModelGrid('generator', []);
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
} else if (!state.aiModelPhotos.length) {
setAiModelStatus('generator-ai-model-status', 'Bereit für sichere AI-Model-Fotos.', 'neutral');
}
updateGeneratorReadiness();
}
// --- Image tools ---
function setupImageTools() {
document.getElementById('advanced-image-editor-btn')?.addEventListener('click', () => {
if (!state.photos.length) return toast('Bitte zuerst mindestens ein Foto hochladen.');
openPhotoEditor(0, 'generator');
});
}
// --- Generate ---
function setupGenerate() {
document.getElementById('generate-btn').addEventListener('click', generateListing);
document.getElementById('reset-generator-btn').addEventListener('click', resetGenerator);
document.getElementById('language-select').addEventListener('change', e => { state.language = e.target.value; updateGeneratorReadiness(); });
document.getElementById('variants-select').addEventListener('change', e => { state.variants = parseInt(e.target.value); });
document.getElementById('template-select').addEventListener('change', e => applyGeneratorTemplate(e.target.value));
document.getElementById('generator-ai-model-btn')?.addEventListener('click', runGeneratorAiModelPhotos);
document.getElementById('generator-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('generator'));
document.getElementById('generator-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('generator'));
updateAiModeControls('generator');
}
let generatorRenderSequence = 0;
function getGeneratorListingForHtml() {
const tags = (document.getElementById('result-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean);
return {
platform: state.platform,
title: document.getElementById('result-title')?.value || '',
description: editors.result ? editors.result.getText().trim() : '',
description_html: getEditorHtml(editors.result),
tags,
brand: document.getElementById('meta-brand')?.value || null,
category: metaCategoryPicker?.getValue?.() || null,
size: document.getElementById('meta-size')?.value || null,
color: document.getElementById('meta-color')?.value || null,
condition: document.getElementById('meta-condition')?.value || null,
};
}
const refreshGeneratorPlatformPreview = debounce(async () => {
if (!state.currentListing || document.getElementById('result-section')?.classList.contains('hidden')) return;
const sequence = ++generatorRenderSequence;
setHtmlSyncStatus('generator-html-sync-status', 'Wird aktualisiert …', 'working');
try {
const listing = getGeneratorListingForHtml();
const data = await api('POST', '/api/html-render', { platform: state.platform, listing });
if (sequence !== generatorRenderSequence) return;
state.currentHtml = data.html || listing.description_html;
setSandboxPreview(document.getElementById('html-preview'), state.currentHtml);
setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok');
} catch (err) {
if (sequence !== generatorRenderSequence) return;
setSandboxPreview(document.getElementById('html-preview'), getEditorHtml(editors.result));
setHtmlSyncStatus('generator-html-sync-status', 'Vorschau lokal', 'warning');
}
}, 140);
function setupGeneratorHtmlSync() {
const editor = editors.result;
const source = document.getElementById('html-source');
if (!editor || !source) return;
editor.on('text-change', () => {
if (htmlSyncState.generatorLock) return;
source.value = getEditorHtml(editor);
updateGeneratorResultMetrics();
refreshGeneratorPlatformPreview();
});
const applySourceToEditor = debounce(() => {
htmlSyncState.generatorLock = true;
const safe = sanitizeRichHtmlClient(source.value);
setEditorHtml(editor, safe);
htmlSyncState.generatorLock = false;
source.value = safe;
updateGeneratorResultMetrics();
refreshGeneratorPlatformPreview();
}, 100);
source.addEventListener('input', () => {
setHtmlSyncStatus('generator-html-sync-status', 'Code wird übernommen …', 'working');
setSandboxPreview(document.getElementById('html-preview'), sanitizeRichHtmlClient(source.value));
applySourceToEditor();
});
}
function setHtmlView(scope, view) {
document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => {
const active = button.dataset.htmlView === view;
button.classList.toggle('is-active', active);
button.setAttribute('aria-selected', active ? 'true' : 'false');
});
const root = scope === 'generator' ? document.getElementById('html-preview-wrap') : document.querySelector('.detail-platform-workbench');
root?.querySelectorAll('.html-switch-view').forEach(panel => panel.classList.toggle('is-active', panel.dataset.htmlPanel === view));
}
function bindHtmlViewTabs(scope) {
document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => {
if (button.dataset.bound === '1') return;
button.dataset.bound = '1';
button.addEventListener('click', () => setHtmlView(scope, button.dataset.htmlView));
});
}
function setupHtmlViewSwitches() {
bindHtmlViewTabs('generator');
setHtmlView('generator', 'preview');
}
function setupMobileUpload() {
document.getElementById('generator-mobile-upload')?.addEventListener('click', () => openMobileUpload('generator'));
document.getElementById('mobile-upload-close')?.addEventListener('click', closeMobileUpload);
document.getElementById('mobile-upload-done')?.addEventListener('click', closeMobileUpload);
document.getElementById('mobile-upload-new-session')?.addEventListener('click', () => openMobileUpload(mobileUploadState.target?.context || 'generator', mobileUploadState.target?.listingId, true));
document.getElementById('mobile-upload-copy-link')?.addEventListener('click', async () => {
const input = document.getElementById('mobile-upload-link');
if (!input?.value) return;
await navigator.clipboard.writeText(input.value);
toast('Upload-Link kopiert');
});
document.getElementById('mobile-upload-overlay')?.addEventListener('click', event => {
if (event.target.id === 'mobile-upload-overlay') closeMobileUpload();
});
}
async function openMobileUpload(context = 'generator', listingId = null, renew = false) {
if (renew && mobileUploadState.session?.id) {
try { await api('DELETE', `/api/mobile-upload-sessions/${mobileUploadState.session.id}`); } catch {}
}
stopMobileUploadPolling();
mobileUploadState.target = { context, listingId: listingId || null };
mobileUploadState.imported = new Set(context === 'listing' ? (detailEditorState?.photos || []) : state.photos);
const overlay = document.getElementById('mobile-upload-overlay');
overlay?.classList.remove('hidden');
overlay?.setAttribute('aria-hidden', 'false');
setMobileUploadDialogState('Link wird vorbereitet …', 'working');
try {
const session = await api('POST', '/api/mobile-upload-sessions', { context, listing_id: listingId || null });
mobileUploadState.session = session;
const link = document.getElementById('mobile-upload-link');
if (link) link.value = session.url;
const qrCanvas = document.getElementById('mobile-upload-qr');
if (typeof QRious !== 'undefined' && qrCanvas) new QRious({ element: qrCanvas, value: session.url, size: 240, level: 'M', foreground: '#171714', background: '#ffffff' });
const expiry = new Date(session.expires_at);
document.getElementById('mobile-upload-expiry').textContent = `Gültig bis ${expiry.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
const localOnly = /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(session.url);
const networkNote = document.getElementById('mobile-upload-network-note');
if (networkNote) {
networkNote.textContent = localOnly
? 'Keine LAN-IP erkannt. Prüfe Windows-Firewall und Netzwerkadapter oder setze PUBLIC_BASE_URL manuell.'
: `Handy und Vendoo-PC müssen im selben Netzwerk sein. Erkannte Adresse: ${session.base_url || session.url.split('/mobile-upload/')[0]}`;
}
setMobileUploadDialogState(localOnly ? 'LAN-Adresse prüfen' : 'Bereit zum Scannen', localOnly ? 'warning' : 'ready');
updateMobileUploadCount([]);
mobileUploadState.pollTimer = window.setInterval(pollMobileUploadSession, 1500);
} catch (error) {
setMobileUploadDialogState(error.message, 'error');
}
}
function setMobileUploadDialogState(text, tone) {
const element = document.getElementById('mobile-upload-session-state');
if (!element) return;
element.lastChild.textContent = text;
element.dataset.tone = tone;
}
function updateMobileUploadCount(files) {
const count = document.getElementById('mobile-upload-file-count');
if (count) count.textContent = `${files.length} ${files.length === 1 ? 'Bild' : 'Bilder'} empfangen`;
}
async function pollMobileUploadSession() {
const session = mobileUploadState.session;
if (!session?.id) return;
try {
const data = await api('GET', `/api/mobile-upload-sessions/${session.id}`);
const files = Array.isArray(data.files) ? data.files : [];
const fresh = files.filter(file => !mobileUploadState.imported.has(file));
fresh.forEach(file => mobileUploadState.imported.add(file));
if (fresh.length) {
if (mobileUploadState.target?.context === 'listing' && detailEditorState) {
detailEditorState.photos = [...new Set([...detailEditorState.photos, ...fresh])].slice(0, 10);
renderDetailPhotos();
} else {
state.photos = [...new Set([...state.photos, ...fresh])].slice(0, 10);
renderPhotoPreviews();
updateGeneratorReadiness();
}
toast(`${fresh.length} ${fresh.length === 1 ? 'Bild empfangen' : 'Bilder empfangen'}`);
}
updateMobileUploadCount(files);
if (files.length) setMobileUploadDialogState('Bilder werden live übertragen', 'ready');
} catch (error) {
setMobileUploadDialogState(error.message, 'error');
stopMobileUploadPolling();
}
}
function stopMobileUploadPolling() {
if (mobileUploadState.pollTimer) window.clearInterval(mobileUploadState.pollTimer);
mobileUploadState.pollTimer = null;
}
function closeMobileUpload() {
stopMobileUploadPolling();
const overlay = document.getElementById('mobile-upload-overlay');
overlay?.classList.add('hidden');
overlay?.setAttribute('aria-hidden', 'true');
}
function setupGeneratorStudio() {
const notes = document.getElementById('generator-seller-notes');
notes?.addEventListener('input', () => {
const count = document.getElementById('generator-notes-count');
if (count) count.textContent = `${notes.value.length} / 500`;
});
document.getElementById('result-title')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); });
document.getElementById('result-tags')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); });
document.getElementById('meta-price')?.addEventListener('input', debounce(updateGeneratorFeePreview, 250));
document.getElementById('generator-download-photos')?.addEventListener('click', () => downloadPublishPhotos(state.photos));
['meta-brand', 'meta-size', 'meta-color', 'meta-condition'].forEach(id => {
document.getElementById(id)?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); });
});
updateGeneratorPhotoStage();
updateGeneratorReadiness();
updateGeneratorResultMetrics();
}
function applyGeneratorTemplate(value) {
const tpl = state.templates.find(t => t.id === parseInt(value));
const notes = document.getElementById('generator-seller-notes');
const tags = document.getElementById('generator-template-tags');
if (!tpl) {
if (notes) notes.value = '';
if (tags) { tags.innerHTML = ''; tags.classList.add('hidden'); }
const count = document.getElementById('generator-notes-count');
if (count) count.textContent = '0 / 500';
return;
}
if (tpl.platform) {
state.platform = tpl.platform;
renderPlatformButtons();
}
if (tpl.language) {
state.language = tpl.language;
document.getElementById('language-select').value = tpl.language;
}
if (notes) notes.value = (tpl.seller_notes || '').slice(0, 500);
const count = document.getElementById('generator-notes-count');
if (count) count.textContent = `${notes?.value.length || 0} / 500`;
const defaultTags = Array.isArray(tpl.default_tags) ? tpl.default_tags : [];
if (tags) {
tags.innerHTML = defaultTags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('');
tags.classList.toggle('hidden', defaultTags.length === 0);
}
updateGeneratorReadiness();
}
function updateGeneratorPhotoStage() {
const count = state.photos.length;
const cover = document.getElementById('generator-cover');
const empty = document.getElementById('generator-photo-empty');
const badge = document.getElementById('generator-cover-badge');
const countEl = document.getElementById('generator-photo-count');
if (countEl) countEl.textContent = `${count} / 10`;
if (cover && empty && badge) {
if (count) {
cover.src = photoUrl(state.photos[0]);
cover.classList.remove('hidden');
empty.classList.add('hidden');
badge.classList.remove('hidden');
} else {
cover.removeAttribute('src');
cover.classList.add('hidden');
empty.classList.remove('hidden');
badge.classList.add('hidden');
}
}
const completed = {
count: count >= 3,
cover: count >= 1,
details: count >= 4,
background: count >= 2,
order: count >= 2,
};
let score = 0;
document.querySelectorAll('#generator-photo-tips [data-photo-tip]').forEach(item => {
const done = Boolean(completed[item.dataset.photoTip]);
item.classList.toggle('is-complete', done);
if (done) score += 1;
});
const scoreEl = document.getElementById('generator-photo-quality-score');
if (scoreEl) scoreEl.textContent = `${score} / 5`;
document.getElementById('generator-step-photos')?.classList.toggle('is-complete', count > 0);
}
function updateGeneratorReadiness() {
const wrapper = document.querySelector('.generator-readiness');
const title = document.getElementById('generator-readiness-title');
const copy = document.getElementById('generator-readiness-copy');
const ready = state.photos.length > 0;
wrapper?.classList.toggle('is-ready', ready);
if (title) title.textContent = ready ? 'Bereit zur Analyse' : 'Fotos fehlen';
if (copy) copy.textContent = ready
? `${state.photos.length} Foto${state.photos.length === 1 ? '' : 's'} · ${platformLabel(state.platform)} · ${state.language.toUpperCase()}`
: 'Lade mindestens ein Produktfoto hoch.';
document.getElementById('generator-step-ai')?.classList.toggle('is-active', ready && !state.currentListing);
}
function updateGeneratorResultMetrics() {
const title = document.getElementById('result-title')?.value || '';
const description = editors.result ? editors.result.getText().trim() : '';
const tagValue = document.getElementById('result-tags')?.value || '';
const tags = tagValue.split(',').map(tag => tag.trim()).filter(Boolean);
const titleCount = document.getElementById('result-title-count');
const descriptionCount = document.getElementById('result-description-count');
const tagsCount = document.getElementById('result-tags-count');
if (titleCount) titleCount.textContent = `${title.length} / 80`;
if (descriptionCount) descriptionCount.textContent = `${description.length} Zeichen`;
if (tagsCount) tagsCount.textContent = `${tags.length} Tag${tags.length === 1 ? '' : 's'}`;
let score = 0;
if (title.length >= 30 && title.length <= 80) score += 25;
else if (title.length >= 15) score += 16;
if (description.length >= 180) score += 30;
else if (description.length >= 80) score += 18;
if (tags.length >= 5) score += 15;
else if (tags.length) score += 8;
if (document.getElementById('meta-brand')?.value) score += 8;
if (document.getElementById('meta-category-picker')?.textContent?.trim()) score += 8;
if (parseFloat(document.getElementById('meta-price')?.value)) score += 8;
if (state.photos.length >= 3) score += 6;
score = Math.min(100, score);
const scoreEl = document.getElementById('generator-quality-score');
const ring = document.getElementById('generator-quality-ring');
const label = document.getElementById('generator-quality-label');
if (scoreEl) scoreEl.textContent = `${score}%`;
if (ring) ring.style.setProperty('--quality', `${score}%`);
if (label) {
label.textContent = score >= 85 ? 'Sehr gut' : score >= 65 ? 'Gut' : score >= 40 ? 'Ausbaufähig' : 'Unvollständig';
label.style.color = score >= 65 ? 'var(--vd-success)' : score >= 40 ? 'var(--vd-warning)' : 'var(--vd-danger)';
}
}
async function updateGeneratorFeePreview() {
const price = parseFloat(document.getElementById('meta-price')?.value) || 0;
const feeEl = document.getElementById('fee-info');
if (!feeEl || !price) {
feeEl?.classList.add('hidden');
updateGeneratorResultMetrics();
return;
}
try {
const fees = await api('GET', `/api/fees?platform=${encodeURIComponent(state.platform)}&price=${encodeURIComponent(price)}`);
feeEl.innerHTML = `Gebühren: ${fees.fees} € (${escapeHtml(fees.breakdown || '')}) → <span class="fee-net">Netto: ${fees.net} €</span>`;
feeEl.classList.remove('hidden');
} catch {}
updateGeneratorResultMetrics();
}
async function generateListing() {
const btn = document.getElementById('generate-btn');
const loading = document.getElementById('loading');
btn.disabled = true;
document.getElementById('result-section').classList.add('hidden');
document.getElementById('meta-section').classList.add('hidden');
document.getElementById('variants-section').classList.add('hidden');
document.getElementById('generator-preview-empty')?.classList.remove('hidden');
const previewStatus = document.getElementById('generator-preview-status');
if (previewStatus) { previewStatus.textContent = 'Analysiert'; previewStatus.className = 'generator-preview-status is-working'; }
document.getElementById('generator-step-ai')?.classList.add('is-active');
loading.classList.remove('hidden');
try {
const body = {
photos: state.photos,
platform: state.platform,
ai_provider: state.aiProvider,
language: state.language,
variants: state.variants,
template_id: parseInt(document.getElementById('template-select')?.value) || null,
seller_notes: document.getElementById('generator-seller-notes')?.value.trim() || '',
};
if (state.aiModel) body.ai_model = state.aiModel;
const data = await api('POST', '/api/generate', body);
if (data.meta) showMeta(data.meta, data.fees);
if (data.variants) {
state.allVariants = data.variants;
showVariantTabs(data.variants);
showListing(data.variants[0], data.variants[0].description_html);
} else {
state.allVariants = null;
document.getElementById('variants-section').classList.add('hidden');
showListing(data.listing, data.description_html);
}
setTimeout(fetchPriceSuggestion, 500);
} catch (err) {
const previewStatus = document.getElementById('generator-preview-status');
if (previewStatus) { previewStatus.textContent = 'Fehler'; previewStatus.className = 'generator-preview-status'; }
document.getElementById('generator-step-ai')?.classList.remove('is-active');
toast('Fehler: ' + err.message);
} finally {
loading.classList.add('hidden');
btn.disabled = state.photos.length === 0;
updateGeneratorReadiness();
}
}
async function showMeta(meta, fees) {
document.getElementById('meta-sku').value = state.currentListing?.sku || '';
document.getElementById('meta-brand').value = meta.brand || '';
document.getElementById('meta-size').value = meta.size || '';
document.getElementById('meta-color').value = meta.color || '';
document.getElementById('meta-condition').value = meta.condition || '';
document.getElementById('meta-price').value = meta.suggested_price || '';
document.getElementById('meta-section').classList.remove('hidden');
updateGeneratorResultMetrics();
let matchedCategory = null;
if (meta.category) {
try {
const matches = await api('GET', `/api/categories/${state.platform}/match?q=${encodeURIComponent(meta.category)}`);
if (matches.length && matches[0].score >= 50) matchedCategory = matches[0].pathStr;
} catch {}
}
metaCategoryPicker = createCategoryPicker('meta-category-picker', state.platform, matchedCategory || meta.category || null);
const feeEl = document.getElementById('fee-info');
if (fees) {
feeEl.innerHTML = `Gebühren: ${fees.fees} € (${fees.breakdown}) → <span class="fee-net">Netto: ${fees.net} €</span>`;
feeEl.classList.remove('hidden');
} else { feeEl.classList.add('hidden'); }
}
function getEditedMeta() {
return {
brand: document.getElementById('meta-brand').value || null,
category: (metaCategoryPicker ? metaCategoryPicker.getValue() : null) || null,
size: document.getElementById('meta-size').value || null,
color: document.getElementById('meta-color').value || null,
condition: document.getElementById('meta-condition').value || null,
price: parseFloat(document.getElementById('meta-price').value) || null,
storage_location: document.getElementById('meta-storage-location').value || null,
};
}
function showVariantTabs(variants) {
const c = document.getElementById('variant-tabs');
c.innerHTML = '';
variants.forEach((v, i) => {
const btn = document.createElement('button');
btn.className = 'variant-tab' + (i === 0 ? ' active' : '');
btn.textContent = `Variante ${i + 1}`;
btn.addEventListener('click', () => {
c.querySelectorAll('.variant-tab').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
showListing(v, v.description_html);
});
c.appendChild(btn);
});
document.getElementById('variants-section').classList.remove('hidden');
}
function showListing(listing, platformHtml) {
state.currentListing = listing;
const editorHtml = listing.id && listing.description_html
? sanitizeRichHtmlClient(listing.description_html)
: plainTextToRichHtml(listing.description || '');
state.currentHtml = platformHtml || '';
const skuEl = document.getElementById('meta-sku');
if (skuEl && listing.sku) skuEl.value = listing.sku;
document.getElementById('result-title').value = listing.title || '';
htmlSyncState.generatorLock = true;
setEditorHtml(editors.result, editorHtml);
htmlSyncState.generatorLock = false;
document.getElementById('html-source').value = getEditorHtml(editors.result);
setSandboxPreview(document.getElementById('html-preview'), state.currentHtml || editorHtml);
document.getElementById('html-preview-wrap')?.classList.remove('hidden');
setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok');
const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || '');
document.getElementById('result-tags').value = tags;
const plat = state.platforms.find(p => p.id === state.platform);
document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags';
document.getElementById('save-btn').textContent = listing.id ? 'Änderungen speichern' : 'Speichern & Hinzufügen';
document.getElementById('generator-preview-empty')?.classList.add('hidden');
document.getElementById('result-section').classList.remove('hidden');
const previewStatus = document.getElementById('generator-preview-status');
if (previewStatus) { previewStatus.textContent = 'Bereit zur Prüfung'; previewStatus.className = 'generator-preview-status is-ready'; }
document.getElementById('generator-step-ai')?.classList.remove('is-active');
document.getElementById('generator-step-ai')?.classList.add('is-complete');
document.getElementById('generator-step-result')?.classList.add('is-active');
updateGeneratorResultMetrics();
refreshGeneratorPlatformPreview();
}
// --- Copy ---
function setupCopy() {
document.querySelectorAll('.copy-btn[data-target]').forEach(btn => {
btn.addEventListener('click', () => {
navigator.clipboard.writeText(document.getElementById(btn.dataset.target).value);
toast('Kopiert!');
});
});
document.getElementById('copy-desc-text').addEventListener('click', () => {
if (editors.result) {
navigator.clipboard.writeText(editors.result.getText().trim());
toast('Text kopiert!');
}
});
document.getElementById('copy-desc-html').addEventListener('click', () => {
const src = document.getElementById('html-source').value;
if (src) {
navigator.clipboard.writeText(src);
toast('Editor-HTML kopiert!');
}
});
document.getElementById('copy-platform-html')?.addEventListener('click', () => {
const src = state.currentHtml || document.getElementById('html-source').value;
if (src) {
navigator.clipboard.writeText(src);
toast('Plattform-HTML kopiert!');
}
});
document.getElementById('format-html-source')?.addEventListener('click', () => {
const source = document.getElementById('html-source');
const safe = sanitizeRichHtmlClient(source.value);
source.value = safe;
htmlSyncState.generatorLock = true;
setEditorHtml(editors.result, safe);
htmlSyncState.generatorLock = false;
refreshGeneratorPlatformPreview();
toast('HTML bereinigt und synchronisiert');
});
document.getElementById('copy-all-btn').addEventListener('click', () => {
const t = document.getElementById('result-title').value;
const d = editors.result ? editors.result.getText().trim() : '';
const tg = document.getElementById('result-tags').value;
navigator.clipboard.writeText(`${t}\n\n${d}\n\n${tg}`);
toast('Alles kopiert!');
});
document.getElementById('save-btn').addEventListener('click', async () => {
if (!state.currentListing) return;
const tags = document.getElementById('result-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const meta = getEditedMeta();
const descriptionHtml = getEditorHtml(editors.result);
const description = richHtmlToText(descriptionHtml);
const payload = {
title: document.getElementById('result-title').value,
description,
description_html: descriptionHtml,
tags,
photos: [...state.photos],
platform: state.platform,
ai_provider: state.aiProvider,
language: state.language,
...meta,
};
try {
if (state.currentListing.id) {
await api('PUT', `/api/listings/${state.currentListing.id}`, { ...payload, _edit_lock: listingEditLock.listingId === Number(state.currentListing.id) ? listingEditLock.token : null });
toast('Gespeichert!');
} else {
await api('POST', '/api/listings', {
...payload,
seller_notes: state.currentListing?.seller_notes || null,
});
toast('Listing hinzugefügt!');
notify('Vendoo', `"${payload.title}" gespeichert`);
resetGenerator();
}
} catch (err) { toast('Fehler: ' + err.message); }
});
}
function resetGenerator() {
state.currentListing = null;
state.currentHtml = '';
state.allVariants = null;
state.photos = [];
state.aiModelPhotos = [];
metaCategoryPicker = null;
document.getElementById('photo-preview').innerHTML = '';
document.getElementById('image-tools').classList.add('hidden');
document.getElementById('result-section').classList.add('hidden');
document.getElementById('meta-section').classList.add('hidden');
document.getElementById('variants-section').classList.add('hidden');
document.getElementById('generator-preview-empty')?.classList.remove('hidden');
document.getElementById('save-btn').textContent = 'Speichern & Hinzufügen';
document.getElementById('generate-btn').disabled = true;
renderAiModelGrid('generator', []);
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
document.getElementById('template-select').value = '';
const notes = document.getElementById('generator-seller-notes');
if (notes) notes.value = '';
const notesCount = document.getElementById('generator-notes-count');
if (notesCount) notesCount.textContent = '0 / 500';
const templateTags = document.getElementById('generator-template-tags');
if (templateTags) { templateTags.innerHTML = ''; templateTags.classList.add('hidden'); }
if (editors.result) editors.result.setText('');
const htmlSource = document.getElementById('html-source');
if (htmlSource) htmlSource.value = '';
setSandboxPreview(document.getElementById('html-preview'), '');
document.getElementById('result-title').value = '';
document.getElementById('result-tags').value = '';
const previewStatus = document.getElementById('generator-preview-status');
if (previewStatus) { previewStatus.textContent = 'Wartet'; previewStatus.className = 'generator-preview-status'; }
['generator-step-ai', 'generator-step-result'].forEach(id => {
document.getElementById(id)?.classList.remove('is-active', 'is-complete');
});
document.getElementById('generator-step-photos')?.classList.remove('is-complete');
document.getElementById('generator-step-photos')?.classList.add('is-active');
updateGeneratorPhotoStage();
updateGeneratorReadiness();
updateGeneratorResultMetrics();
}
// --- Listings / Historie ---
const historyState = {
selected: new Set(),
listings: [],
allFiltered: [],
publishStatus: {},
hiddenColumns: new Set(),
};
const HISTORY_COLUMN_STORAGE = 'vendoo-history-columns';
function setupHistory() {
const search = document.getElementById('search-input');
const platform = document.getElementById('filter-platform');
const status = document.getElementById('filter-status');
const period = document.getElementById('history-period');
const dateFrom = document.getElementById('filter-date-from');
const dateTo = document.getElementById('filter-date-to');
search.addEventListener('input', debounce(() => { state.historyPage = 1; loadHistory(); }, 300));
platform.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
status.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
period.addEventListener('change', () => {
applyHistoryPeriod(period.value);
state.historyPage = 1;
loadHistory();
});
dateFrom.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
dateTo.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
document.getElementById('history-reset-filters').addEventListener('click', () => {
search.value = '';
platform.value = '';
status.value = '';
period.value = '90';
applyHistoryPeriod('90');
state.historyPage = 1;
loadHistory();
});
document.getElementById('back-to-list').addEventListener('click', async () => { await releaseListingLock({ quiet: true }); setHistoryDetailMode(false); });
document.getElementById('history-select-all').addEventListener('change', e => {
const checked = e.target.checked;
historyState.listings.forEach(listing => {
if (checked) historyState.selected.add(listing.id);
else historyState.selected.delete(listing.id);
});
document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = checked; });
document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.toggle('selected', checked));
updateHistoryToolbar();
});
document.getElementById('history-clear-selection').addEventListener('click', () => {
historyState.selected.clear();
document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = false; });
document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.remove('selected'));
document.getElementById('history-select-all').checked = false;
updateHistoryToolbar();
});
document.getElementById('history-delete-selected').addEventListener('click', deleteSelectedHistoryListings);
document.getElementById('history-delete-all').addEventListener('click', async () => {
if (!historyState.allFiltered.length) return;
if (!confirm(`Alle ${historyState.allFiltered.length} gefilterten Artikel in den Papierkorb verschieben?`)) return;
for (const listing of historyState.allFiltered) await api('DELETE', `/api/listings/${listing.id}`);
historyState.selected.clear();
toast('Alle gefilterten Artikel wurden in den Papierkorb verschoben');
await loadHistory();
updateTrashBadge();
});
document.getElementById('history-bulk-status').addEventListener('click', bulkUpdateHistoryStatus);
document.getElementById('history-price-selected').addEventListener('click', bulkUpdateHistoryPrice);
document.getElementById('history-duplicate-selected').addEventListener('click', bulkDuplicateHistoryListings);
document.getElementById('history-edit-selected').addEventListener('click', () => {
const ids = [...historyState.selected];
if (ids.length !== 1) { toast('Zum Nachbearbeiten bitte genau ein Artikel auswählen'); return; }
showDetail(ids[0]);
});
document.getElementById('history-publish-selected').addEventListener('click', () => {
const ids = [...historyState.selected];
if (!ids.length) return;
publishState.selected = new Set(ids);
switchToTab('publish');
toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} für Publish ausgewählt`);
});
document.getElementById('history-print-labels').addEventListener('click', () => {
const selected = historyState.allFiltered.filter(listing => historyState.selected.has(listing.id));
if (selected.length) printLabels(selected);
});
document.getElementById('export-csv-btn').addEventListener('click', () => {
const params = new URLSearchParams();
if (platform.value) params.set('platform', platform.value);
if (status.value) params.set('status', status.value);
window.open(`/api/export/csv?${params}`, '_blank');
closeHistoryPopovers();
});
document.getElementById('export-json-btn').addEventListener('click', () => {
const data = historyState.allFiltered;
if (!data.length) { toast('Keine Artikel zum Exportieren'); return; }
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const anchor = document.createElement('a');
anchor.href = URL.createObjectURL(blob);
anchor.download = `vendoo-export-${new Date().toISOString().slice(0, 10)}.json`;
anchor.click();
URL.revokeObjectURL(anchor.href);
toast(`${data.length} Artikel als JSON exportiert`);
closeHistoryPopovers();
});
setupHistoryPopovers();
setupHistoryColumns();
const pageSizeSelect = document.getElementById('history-page-size-select');
pageSizeSelect.value = String(state.historyPageSize);
pageSizeSelect.addEventListener('change', () => {
state.historyPageSize = Number(pageSizeSelect.value);
state.historyPage = 1;
loadHistory();
});
setupPagination('history');
applyHistoryPeriod(period.value || '90');
}
function applyHistoryPeriod(value) {
const custom = document.getElementById('history-custom-dates');
const fromInput = document.getElementById('filter-date-from');
const toInput = document.getElementById('filter-date-to');
custom.classList.toggle('hidden', value !== 'custom');
if (value === 'custom') return;
const today = new Date();
const iso = date => date.toISOString().slice(0, 10);
toInput.value = value === 'all' ? '' : iso(today);
if (value === 'all') {
fromInput.value = '';
} else if (value === 'year') {
fromInput.value = `${today.getFullYear()}-01-01`;
} else {
const days = Number(value) || 90;
const from = new Date(today);
from.setDate(from.getDate() - days + 1);
fromInput.value = iso(from);
}
}
function setupHistoryPopovers() {
const exportButton = document.getElementById('history-export-toggle');
const exportMenu = document.getElementById('history-export-menu');
const columnsButton = document.getElementById('history-columns-btn');
const columnsPanel = document.getElementById('history-columns-panel');
exportButton.addEventListener('click', event => {
event.stopPropagation();
const open = exportMenu.classList.toggle('hidden') === false;
columnsPanel.classList.add('hidden');
exportButton.setAttribute('aria-expanded', String(open));
columnsButton.setAttribute('aria-expanded', 'false');
});
columnsButton.addEventListener('click', event => {
event.stopPropagation();
const open = columnsPanel.classList.toggle('hidden') === false;
exportMenu.classList.add('hidden');
columnsButton.setAttribute('aria-expanded', String(open));
exportButton.setAttribute('aria-expanded', 'false');
});
exportMenu.addEventListener('click', event => event.stopPropagation());
columnsPanel.addEventListener('click', event => event.stopPropagation());
document.addEventListener('click', closeHistoryPopovers);
}
function closeHistoryPopovers() {
document.getElementById('history-export-menu')?.classList.add('hidden');
document.getElementById('history-columns-panel')?.classList.add('hidden');
document.getElementById('history-export-toggle')?.setAttribute('aria-expanded', 'false');
document.getElementById('history-columns-btn')?.setAttribute('aria-expanded', 'false');
document.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden'));
}
function setupHistoryColumns() {
try {
const saved = JSON.parse(localStorage.getItem(HISTORY_COLUMN_STORAGE) || '[]');
historyState.hiddenColumns = new Set(Array.isArray(saved) ? saved : []);
} catch { historyState.hiddenColumns = new Set(); }
document.querySelectorAll('[data-history-column]').forEach(input => {
input.checked = !historyState.hiddenColumns.has(input.dataset.historyColumn);
input.addEventListener('change', () => {
const column = input.dataset.historyColumn;
if (input.checked) historyState.hiddenColumns.delete(column);
else historyState.hiddenColumns.add(column);
localStorage.setItem(HISTORY_COLUMN_STORAGE, JSON.stringify([...historyState.hiddenColumns]));
applyHistoryColumns();
});
});
applyHistoryColumns();
}
function applyHistoryColumns() {
const table = document.getElementById('history-table');
if (!table) return;
['platform', 'provider', 'price', 'status', 'location', 'published'].forEach(column => {
table.classList.toggle(`history-hide-${column}`, historyState.hiddenColumns.has(column));
});
}
function setHistoryDetailMode(enabled) {
document.querySelector('.listings-heading')?.classList.toggle('hidden', enabled);
document.querySelector('.history-filterbar')?.classList.toggle('hidden', enabled);
document.querySelector('.history-table-shell')?.classList.toggle('hidden', enabled);
document.getElementById('history-detail')?.classList.toggle('hidden', !enabled);
}
async function deleteSelectedHistoryListings() {
const ids = [...historyState.selected];
if (!ids.length) return;
if (!confirm(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschieben?`)) return;
for (const id of ids) await api('DELETE', `/api/listings/${id}`);
historyState.selected.clear();
toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschoben`);
await loadHistory();
updateTrashBadge();
}
async function bulkUpdateHistoryStatus() {
const ids = [...historyState.selected];
if (!ids.length) return;
const answer = prompt('Neuer Status: aktiv, verkauft oder reserviert');
if (!answer) return;
const normalized = answer.trim().toLowerCase();
const map = { aktiv: 'active', active: 'active', verkauft: 'sold', sold: 'sold', reserviert: 'reserved', reserved: 'reserved' };
const newStatus = map[normalized];
if (!newStatus) { toast('Unbekannter Status'); return; }
await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { status: newStatus })));
toast(`Status für ${ids.length} Artikel aktualisiert`);
loadHistory();
}
async function bulkUpdateHistoryPrice() {
const ids = [...historyState.selected];
if (!ids.length) return;
const answer = prompt('Neuer Preis in Euro für alle ausgewählten Artikel:');
if (answer === null) return;
const price = Number(String(answer).replace(',', '.'));
if (!Number.isFinite(price) || price < 0) { toast('Bitte einen gültigen Preis eingeben'); return; }
await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { price })));
toast(`Preis für ${ids.length} Artikel aktualisiert`);
loadHistory();
}
async function bulkDuplicateHistoryListings() {
const ids = [...historyState.selected];
if (!ids.length) return;
for (const id of ids) await api('POST', `/api/listings/${id}/duplicate`);
historyState.selected.clear();
toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} dupliziert`);
loadHistory();
}
function setupPagination(prefix) {
const topBar = document.getElementById(`${prefix}-pagination-top`);
if (!topBar) return;
topBar.querySelectorAll('.page-size-btn').forEach(btn => {
btn.addEventListener('click', () => {
topBar.querySelectorAll('.page-size-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
const size = parseInt(btn.dataset.size);
if (prefix === 'history') { state.historyPageSize = size; state.historyPage = 1; loadHistory(); }
else { state.publishPageSize = size; state.publishPage = 1; loadPublishList(); }
});
});
document.getElementById(`${prefix}-prev`)?.addEventListener('click', () => {
if (prefix === 'history') { state.historyPage--; loadHistory(); }
else { state.publishPage--; loadPublishList(); }
});
document.getElementById(`${prefix}-next`)?.addEventListener('click', () => {
if (prefix === 'history') { state.historyPage++; loadHistory(); }
else { state.publishPage++; loadPublishList(); }
});
document.getElementById(`${prefix}-prev-bottom`)?.addEventListener('click', () => {
if (prefix === 'history') { state.historyPage--; loadHistory(); }
else { state.publishPage--; loadPublishList(); }
});
document.getElementById(`${prefix}-next-bottom`)?.addEventListener('click', () => {
if (prefix === 'history') { state.historyPage++; loadHistory(); }
else { state.publishPage++; loadPublishList(); }
});
}
function updatePaginationUI(prefix, page, totalPages, totalItems) {
const info = `Seite ${page} von ${totalPages} (${totalItems} Einträge)`;
document.getElementById(`${prefix}-page-info`)?.replaceChildren(document.createTextNode(info));
document.getElementById(`${prefix}-page-info-bottom`)?.replaceChildren(document.createTextNode(info));
const prevDisabled = page <= 1;
const nextDisabled = page >= totalPages;
const prev = document.getElementById(`${prefix}-prev`);
const next = document.getElementById(`${prefix}-next`);
const prevBottom = document.getElementById(`${prefix}-prev-bottom`);
const nextBottom = document.getElementById(`${prefix}-next-bottom`);
if (prev) prev.disabled = prevDisabled;
if (next) next.disabled = nextDisabled;
if (prevBottom) prevBottom.disabled = prevDisabled;
if (nextBottom) nextBottom.disabled = nextDisabled;
if (prefix === 'history') {
const totalInfo = document.getElementById('history-total-info');
if (totalInfo) totalInfo.textContent = `von ${totalItems} Einträgen`;
renderHistoryPageButtons(page, totalPages);
}
}
function renderHistoryPageButtons(page, totalPages) {
const container = document.getElementById('history-page-buttons');
if (!container) return;
const pages = new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2]);
const valid = [...pages].filter(value => value >= 1 && value <= totalPages).sort((a, b) => a - b);
const parts = [];
let previous = 0;
for (const value of valid) {
if (previous && value - previous > 1) parts.push('<span class="history-page-gap">…</span>');
parts.push(`<button class="history-page-button${value === page ? ' active' : ''}" type="button" data-page="${value}"${value === page ? ' aria-current="page"' : ''}>${value}</button>`);
previous = value;
}
container.innerHTML = parts.join('');
container.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => {
state.historyPage = Number(button.dataset.page);
loadHistory();
}));
}
function paginateList(allItems, page, pageSize) {
if (!pageSize || pageSize <= 0) return { items: allItems, page: 1, totalPages: 1 };
const totalPages = Math.max(1, Math.ceil(allItems.length / pageSize));
const safePage = Math.min(Math.max(1, page), totalPages);
const start = (safePage - 1) * pageSize;
return { items: allItems.slice(start, start + pageSize), page: safePage, totalPages };
}
async function loadHistory() {
const query = document.getElementById('search-input').value.trim();
const platform = document.getElementById('filter-platform').value;
const status = document.getElementById('filter-status').value;
const dateFrom = document.getElementById('filter-date-from').value;
const dateTo = document.getElementById('filter-date-to').value;
const params = new URLSearchParams();
if (query) params.set('q', query);
if (platform) params.set('platform', platform);
if (status) params.set('status', status);
let listings = await api('GET', `/api/listings?${params.toString()}`);
if (dateFrom) {
const from = new Date(`${dateFrom}T00:00:00`);
listings = listings.filter(listing => historyParseDate(listing.created_at) >= from);
}
if (dateTo) {
const to = new Date(`${dateTo}T23:59:59.999`);
listings = listings.filter(listing => historyParseDate(listing.created_at) <= to);
}
historyState.allFiltered = listings;
const { items, page, totalPages } = paginateList(listings, state.historyPage, state.historyPageSize);
state.historyPage = page;
historyState.listings = items;
updatePaginationUI('history', page, totalPages, listings.length);
if (items.length) {
const ids = items.map(listing => listing.id).join(',');
try { historyState.publishStatus = await api('GET', `/api/publish/status?ids=${ids}`); }
catch { historyState.publishStatus = {}; }
} else {
historyState.publishStatus = {};
}
renderHistoryList(items);
}
function historyParseDate(value) {
if (!value) return new Date(0);
const normalized = /(?:Z|[+-]\d\d:\d\d)$/.test(value) ? value : `${value.replace(' ', 'T')}Z`;
const date = new Date(normalized);
return Number.isNaN(date.getTime()) ? new Date(0) : date;
}
function historyFormatDate(value, withTime = false) {
const date = historyParseDate(value);
if (!date.getTime()) return '—';
return date.toLocaleString('de-DE', withTime
? { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }
: { day: '2-digit', month: '2-digit', year: 'numeric' });
}
function historyProviderLabel(provider) {
return ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || '—';
}
function historyStatusMeta(status) {
return ({
active: { label: 'Aktiv', className: 'active' },
sold: { label: 'Verkauft', className: 'sold' },
reserved: { label: 'Reserviert', className: 'reserved' },
})[status] || { label: status || 'Aktiv', className: 'active' };
}
function historyPublishSummary(listing) {
const statuses = historyState.publishStatus[listing.id] || {};
const entries = Object.entries(statuses);
if (!entries.length) return '<span class="history-publish-empty">Nicht veröffentlicht</span>';
return entries.slice(0, 2).map(([platform, status]) => {
const meta = {
published: ['Live', 'published'],
copied: ['Kopiert', 'copied'],
pending: ['Ausstehend', 'pending'],
failed: ['Fehler', 'failed'],
}[status] || [status, 'pending'];
return `<span class="history-publish-badge ${meta[1]}"><span>${escapeHtml(platformLabel(platform))}</span>${escapeHtml(meta[0])}</span>`;
}).join('');
}
function renderHistoryList(listings) {
const container = document.getElementById('history-list');
if (!listings.length) {
container.innerHTML = `<div class="history-empty-state">
<div class="history-empty-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h6"/></svg></div>
<h3>Keine Artikel gefunden</h3>
<p>Ändere die Filter oder erstelle ein neues Listing.</p>
<button class="vd-button primary" type="button" data-empty-new-listing>Neuer Artikel</button>
</div>`;
container.querySelector('[data-empty-new-listing]')?.addEventListener('click', () => switchToTab('generator'));
updateHistoryToolbar();
applyHistoryColumns();
return;
}
container.innerHTML = listings.map(listing => {
const checked = historyState.selected.has(listing.id);
const photo = listing.photos?.[0]
? `<img class="history-thumb" src="${escapeHtml(photoUrl(listing.photos[0]))}" alt="" loading="lazy">`
: '<div class="history-thumb history-thumb-placeholder" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="m4 7 8-4 8 4-8 4Z"/><path d="m4 7 8 4 8-4v10l-8 4-8-4Z"/><path d="M12 11v10"/></svg></div>';
const status = historyStatusMeta(listing.status || 'active');
const provider = historyProviderLabel(listing.ai_provider);
const platform = platformLabel(listing.platform);
const price = listing.price !== null && listing.price !== undefined ? formatCurrency(listing.price) : '—';
const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`;
return `<div class="history-row${checked ? ' selected' : ''}" data-id="${listing.id}" role="row">
<div class="history-cell history-cell-select" role="cell"><input class="history-row-select" type="checkbox" data-id="${listing.id}" ${checked ? 'checked' : ''} aria-label="${escapeHtml(listing.title || 'Listing')} auswählen"></div>
<div class="history-cell history-cell-article" role="cell">
${photo}
<div class="history-article-copy">
<button class="history-article-title" type="button" data-open-listing="${listing.id}">${escapeHtml(listing.title || 'Ohne Titel')}</button>
<div class="history-article-sku">SKU&nbsp;&nbsp;${escapeHtml(sku)}</div>
</div>
</div>
<div class="history-cell history-cell-platform" data-column="platform" role="cell"><span class="history-platform-name platform-${escapeHtml(listing.platform || '')}">${escapeHtml(platform)}</span><span class="history-cell-sub">${escapeHtml((listing.language || 'de').toUpperCase())}</span></div>
<div class="history-cell history-cell-provider" data-column="provider" role="cell"><span class="history-provider-mark">${escapeHtml(provider.slice(0, 1))}</span><span><strong>${escapeHtml(provider)}</strong><small>Vision AI</small></span></div>
<div class="history-cell history-cell-price" data-column="price" role="cell"><strong>${escapeHtml(price)}</strong><span class="history-cell-sub">Festpreis</span></div>
<div class="history-cell history-cell-status" data-column="status" role="cell">
<select class="history-status-select status-${status.className}" data-id="${listing.id}" aria-label="Status ändern">
<option value="active"${listing.status === 'active' || !listing.status ? ' selected' : ''}>Aktiv</option>
<option value="sold"${listing.status === 'sold' ? ' selected' : ''}>Verkauft</option>
<option value="reserved"${listing.status === 'reserved' ? ' selected' : ''}>Reserviert</option>
</select>
<span class="history-cell-sub">Seit ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at))}</span>
</div>
<div class="history-cell history-cell-location" data-column="location" role="cell"><strong>${escapeHtml(listing.storage_location || 'Nicht zugewiesen')}</strong><span class="history-cell-sub">${escapeHtml(listing.category || '—')}</span></div>
<div class="history-cell history-cell-published" data-column="published" role="cell"><span class="history-publish-date">${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at, true))}</span><div class="history-publish-badges">${historyPublishSummary(listing)}</div></div>
<div class="history-cell history-cell-menu" role="cell">
<button class="history-row-menu-button" type="button" data-row-menu="${listing.id}" aria-label="Aktionen für ${escapeHtml(listing.title || 'Listing')}">•••</button>
<div class="history-row-menu hidden" data-row-menu-panel="${listing.id}">
<button type="button" data-row-action="open" data-id="${listing.id}">Öffnen</button>
<button type="button" data-row-action="duplicate" data-id="${listing.id}">Duplizieren</button>
<button type="button" data-row-action="delete" data-id="${listing.id}" class="danger">Löschen</button>
</div>
</div>
</div>`;
}).join('');
container.querySelectorAll('.history-row-select').forEach(checkbox => {
checkbox.addEventListener('click', event => event.stopPropagation());
checkbox.addEventListener('change', () => {
const id = Number(checkbox.dataset.id);
if (checkbox.checked) historyState.selected.add(id);
else historyState.selected.delete(id);
checkbox.closest('.history-row')?.classList.toggle('selected', checkbox.checked);
updateHistoryToolbar();
});
});
container.querySelectorAll('[data-open-listing]').forEach(button => button.addEventListener('click', event => {
event.stopPropagation();
showDetail(Number(button.dataset.openListing));
}));
container.querySelectorAll('.history-row').forEach(row => row.addEventListener('dblclick', event => {
if (!event.target.closest('button, input, select')) showDetail(Number(row.dataset.id));
}));
container.querySelectorAll('.history-status-select').forEach(select => {
select.addEventListener('click', event => event.stopPropagation());
select.addEventListener('change', async () => {
await api('PUT', `/api/listings/${select.dataset.id}`, { status: select.value });
toast('Status aktualisiert');
loadHistory();
});
});
container.querySelectorAll('[data-row-menu]').forEach(button => button.addEventListener('click', event => {
event.stopPropagation();
const panel = container.querySelector(`[data-row-menu-panel="${button.dataset.rowMenu}"]`);
const willOpen = panel?.classList.contains('hidden');
container.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden'));
if (willOpen) panel?.classList.remove('hidden');
}));
container.querySelectorAll('[data-row-action]').forEach(button => button.addEventListener('click', async event => {
event.stopPropagation();
const id = Number(button.dataset.id);
const action = button.dataset.rowAction;
if (action === 'open') showDetail(id);
if (action === 'duplicate') {
const copy = await api('POST', `/api/listings/${id}/duplicate`);
toast(`Kopie erstellt: „${copy.title}“`);
loadHistory();
}
if (action === 'delete') {
if (!confirm('Listing in den Papierkorb verschieben?')) return;
await api('DELETE', `/api/listings/${id}`);
historyState.selected.delete(id);
toast('Listing in den Papierkorb verschoben');
await loadHistory();
updateTrashBadge();
}
}));
document.getElementById('history-select-all').checked = listings.every(listing => historyState.selected.has(listing.id));
updateHistoryToolbar();
applyHistoryColumns();
}
function updateHistoryToolbar() {
const count = historyState.selected.size;
const countLabel = document.getElementById('history-selected-count');
if (countLabel) countLabel.textContent = `${count} ausgewählt`;
['history-delete-selected', 'history-bulk-status', 'history-print-labels', 'history-publish-selected', 'history-edit-selected', 'history-price-selected', 'history-duplicate-selected']
.forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; });
const clear = document.getElementById('history-clear-selection');
clear?.classList.toggle('hidden', count === 0);
document.getElementById('history-batch-bar')?.classList.toggle('has-selection', count > 0);
const selectAll = document.getElementById('history-select-all');
if (selectAll) {
const selectedOnPage = historyState.listings.filter(listing => historyState.selected.has(listing.id)).length;
selectAll.checked = historyState.listings.length > 0 && selectedOnPage === historyState.listings.length;
selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < historyState.listings.length;
}
}
let detailRenderSequence = 0;
let detailPhotoDragIndex = -1;
function detailPlatformOptions(selected) {
return state.platforms.map(platform => `<option value="${escapeHtml(platform.id)}"${platform.id === selected ? ' selected' : ''}>${escapeHtml(platform.name)}</option>`).join('');
}
function detailProviderOptions(selected) {
return state.providers.map(provider => `<option value="${escapeHtml(provider.id)}"${provider.id === selected ? ' selected' : ''}>${escapeHtml(provider.name)}</option>`).join('');
}
function detailLanguageOptions(selected) {
const languages = [['de', 'Deutsch'], ['en', 'English'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['es', 'Español'], ['it', 'Italiano'], ['pt', 'Português']];
return languages.map(([value, label]) => `<option value="${value}"${value === selected ? ' selected' : ''}>${label}</option>`).join('');
}
function getDetailListingForHtml() {
if (!detailEditorState) return null;
const tags = (document.getElementById('detail-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean);
return {
platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform,
title: document.getElementById('detail-title')?.value || '',
description: editors.detail ? editors.detail.getText().trim() : '',
description_html: getEditorHtml(editors.detail),
tags,
brand: document.getElementById('detail-brand')?.value || null,
category: detailEditorState.categoryPicker?.getValue?.() || null,
size: document.getElementById('detail-size')?.value || null,
color: document.getElementById('detail-color')?.value || null,
condition: document.getElementById('detail-condition')?.value || null,
};
}
const refreshDetailPlatformPreview = debounce(async () => {
const listing = getDetailListingForHtml();
if (!listing) return;
const sequence = ++detailRenderSequence;
setHtmlSyncStatus('detail-html-sync-status', 'Wird aktualisiert …', 'working');
try {
const data = await api('POST', '/api/html-render', { platform: listing.platform, listing });
if (sequence !== detailRenderSequence) return;
detailEditorState.platformHtml = data.html || listing.description_html;
setSandboxPreview(document.getElementById('detail-html-preview'), detailEditorState.platformHtml);
setHtmlSyncStatus('detail-html-sync-status', 'Synchron', 'ok');
} catch {
if (sequence !== detailRenderSequence) return;
setSandboxPreview(document.getElementById('detail-html-preview'), listing.description_html);
setHtmlSyncStatus('detail-html-sync-status', 'Vorschau lokal', 'warning');
}
}, 140);
function setupDetailHtmlSync() {
const editor = editors.detail;
const source = document.getElementById('detail-html-source');
if (!editor || !source) return;
editor.on('text-change', () => {
if (htmlSyncState.detailLock) return;
source.value = getEditorHtml(editor);
const count = document.getElementById('detail-description-count');
if (count) count.textContent = `${editor.getText().trim().length} Zeichen`;
refreshDetailPlatformPreview();
});
const applySource = debounce(() => {
htmlSyncState.detailLock = true;
const safe = sanitizeRichHtmlClient(source.value);
setEditorHtml(editor, safe);
htmlSyncState.detailLock = false;
source.value = safe;
const count = document.getElementById('detail-description-count');
if (count) count.textContent = `${editor.getText().trim().length} Zeichen`;
refreshDetailPlatformPreview();
}, 100);
source.addEventListener('input', () => {
setHtmlSyncStatus('detail-html-sync-status', 'Code wird übernommen …', 'working');
setSandboxPreview(document.getElementById('detail-html-preview'), sanitizeRichHtmlClient(source.value));
applySource();
});
}
function renderDetailPhotos() {
if (!detailEditorState) return;
const photos = detailEditorState.photos;
const hero = document.getElementById('detail-photo-hero');
const empty = document.getElementById('detail-photo-empty');
const strip = document.getElementById('detail-photo-strip');
const count = document.getElementById('detail-photo-count');
const activeIndex = Math.min(detailEditorState.activePhotoIndex || 0, Math.max(0, photos.length - 1));
detailEditorState.activePhotoIndex = activeIndex;
if (count) count.textContent = `${photos.length} / 10`;
if (hero && empty) {
if (photos.length) {
hero.src = photoUrl(photos[activeIndex]);
hero.alt = `Produktfoto ${activeIndex + 1}`;
hero.classList.remove('hidden');
empty.classList.add('hidden');
} else {
hero.removeAttribute('src');
hero.classList.add('hidden');
empty.classList.remove('hidden');
}
}
['detail-edit-photo', 'detail-set-cover', 'detail-remove-photo'].forEach(id => {
const button = document.getElementById(id);
if (button) button.disabled = photos.length === 0;
});
if (!strip) return;
strip.innerHTML = photos.map((photo, index) => `
<button type="button" class="detail-photo-thumb${index === activeIndex ? ' is-active' : ''}${index === 0 ? ' is-cover' : ''}" data-detail-photo-index="${index}" draggable="true" title="Foto ${index + 1}">
<img src="${escapeHtml(photoUrl(photo))}" alt="Foto ${index + 1}" loading="lazy">
${index === 0 ? '<span>Cover</span>' : ''}
</button>`).join('');
strip.querySelectorAll('[data-detail-photo-index]').forEach(button => {
const index = Number(button.dataset.detailPhotoIndex);
button.addEventListener('click', () => { detailEditorState.activePhotoIndex = index; renderDetailPhotos(); });
button.addEventListener('dragstart', event => { detailPhotoDragIndex = index; event.dataTransfer.effectAllowed = 'move'; button.classList.add('is-dragging'); });
button.addEventListener('dragend', () => { detailPhotoDragIndex = -1; button.classList.remove('is-dragging'); });
button.addEventListener('dragover', event => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; });
button.addEventListener('drop', event => {
event.preventDefault();
if (detailPhotoDragIndex < 0 || detailPhotoDragIndex === index) return;
const [moved] = detailEditorState.photos.splice(detailPhotoDragIndex, 1);
detailEditorState.photos.splice(index, 0, moved);
detailEditorState.activePhotoIndex = index;
detailPhotoDragIndex = -1;
renderDetailPhotos();
});
});
}
async function uploadDetailPhotos(files) {
if (!detailEditorState) return;
const available = Math.max(0, 10 - detailEditorState.photos.length);
const accepted = Array.from(files || []).filter(file => file.type.startsWith('image/')).slice(0, available);
if (!available) return toast('Maximal 10 Fotos pro Listing');
if (!accepted.length) return;
const platform = document.getElementById('detail-platform')?.value || detailEditorState.listing.platform || 'allgemein';
const formData = new FormData();
formData.append('platform', platform);
accepted.forEach(file => formData.append('photos', file));
try {
const response = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, {
method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin',
});
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen');
detailEditorState.photos.push(...(data.filenames || []));
detailEditorState.activePhotoIndex = Math.max(0, detailEditorState.photos.length - (data.filenames || []).length);
renderDetailPhotos();
toast(`${(data.filenames || []).length} Foto${(data.filenames || []).length === 1 ? '' : 's'} hinzugefügt`);
} catch (err) { toast('Upload fehlgeschlagen: ' + err.message); }
}
function setupDetailPhotoActions() {
const fileInput = document.getElementById('detail-photo-input');
document.getElementById('detail-add-photo')?.addEventListener('click', () => fileInput?.click());
document.getElementById('detail-mobile-photo')?.addEventListener('click', () => openMobileUpload('listing', detailEditorState?.listing?.id));
fileInput?.addEventListener('change', () => { uploadDetailPhotos(fileInput.files); fileInput.value = ''; });
document.getElementById('detail-edit-photo')?.addEventListener('click', () => openPhotoEditor(detailEditorState.activePhotoIndex, 'detail'));
document.getElementById('detail-set-cover')?.addEventListener('click', () => {
const index = detailEditorState.activePhotoIndex;
if (index <= 0 || !detailEditorState.photos[index]) return;
const [photo] = detailEditorState.photos.splice(index, 1);
detailEditorState.photos.unshift(photo);
detailEditorState.activePhotoIndex = 0;
renderDetailPhotos();
toast('Coverfoto festgelegt');
});
document.getElementById('detail-download-photos')?.addEventListener('click', () => downloadPublishPhotos(detailEditorState?.photos || []));
document.getElementById('detail-remove-photo')?.addEventListener('click', () => {
const index = detailEditorState.activePhotoIndex;
if (!detailEditorState.photos[index]) return;
detailEditorState.photos.splice(index, 1);
detailEditorState.activePhotoIndex = Math.max(0, Math.min(index, detailEditorState.photos.length - 1));
renderDetailPhotos();
});
}
async function showDetail(id) {
const numericId = Number(id);
if (!Number.isInteger(numericId) || numericId <= 0) {
toast('Ungültige Listing-ID');
return;
}
const historySection = document.getElementById('history');
if (!historySection?.classList.contains('active')) {
switchToTab('history');
await new Promise(resolve => requestAnimationFrame(resolve));
}
const l = await api('GET', `/api/listings/${numericId}`);
const tags = Array.isArray(l.tags) ? l.tags.join(', ') : (l.tags || '');
const skuVal = l.sku || `VD-${String(l.id).padStart(4, '0')}`;
detailEditorState = {
listing: l,
photos: [...(Array.isArray(l.photos) ? l.photos : [])],
activePhotoIndex: 0,
categoryPicker: null,
platformHtml: '',
aiModelPhotos: [],
aiModelGenerations: [],
};
document.getElementById('detail-content').innerHTML = `
<div class="listing-editor-shell">
<header class="listing-editor-header">
<div>
<span class="listing-editor-kicker">Artikel vollständig bearbeiten</span>
<h2>${escapeHtml(l.title || 'Unbenanntes Listing')}</h2>
<p>${escapeHtml(skuVal)} · ${escapeHtml(new Date((l.created_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE'))}</p>
</div>
<div class="listing-editor-header-actions">
<button id="detail-copy-all" class="vd-button secondary" type="button">Alles kopieren</button>
<button id="detail-save" class="vd-button primary" type="button">Änderungen speichern</button>
</div>
</header>
<div class="listing-editor-grid">
<aside class="listing-media-editor">
<div class="listing-section-heading"><div><span>01</span><h3>Fotos &amp; Medien</h3></div><b id="detail-photo-count">0 / 10</b></div>
<div class="detail-photo-stage">
<img id="detail-photo-hero" class="hidden" alt="Produktfoto">
<div id="detail-photo-empty" class="detail-photo-empty">Noch keine Fotos</div>
</div>
<div id="detail-photo-strip" class="detail-photo-strip"></div>
<input id="detail-photo-input" type="file" accept="image/*" multiple hidden>
<div class="detail-photo-actions">
<button id="detail-add-photo" class="vd-button compact" type="button">Fotos hinzufügen</button>
<button id="detail-mobile-photo" class="vd-button compact" type="button">Handy-Upload · QR</button>
<button id="detail-edit-photo" class="vd-button compact" type="button">Bild bearbeiten</button>
<button id="detail-set-cover" class="vd-button compact" type="button">Als Cover</button>
<button id="detail-remove-photo" class="vd-button compact danger" type="button">Entfernen</button>
<button id="detail-download-photos" class="vd-button compact" type="button">Fotos herunterladen</button>
</div>
<p class="detail-photo-help">Vorschaubilder ziehen, um die Reihenfolge zu ändern. Das erste Bild ist das Cover.</p>
<section class="ai-model-card detail-ai-model-card">
<div class="ai-model-card-head">
<div>
<span class="generator-panel-index">AI</span>
<div>
<h4>Produktbild-Werkzeuge</h4>
<p>Erzeuge Ghost-Mannequin- oder Flatlay-Varianten aus den aktuellen Listing-Fotos. Freie Prompt-Bilder werden im FLUX Studio erstellt.</p>
</div>
</div>
<span class="generator-beta">Safe</span>
</div>
<div class="ai-model-controls">
<label>Look
<select id="detail-ai-model-preset" class="model-select">
<option value="mixed">Mix</option>
<option value="studio">Studio</option>
<option value="indoor">Indoor</option>
<option value="outdoor">Outdoor</option>
</select>
</label>
<label>Modus
<select id="detail-ai-model-mode" class="model-select">
<option value="ghost_mannequin">Ghost Mannequin</option>
<option value="flatlay" selected>Flatlay</option>
</select>
</label>
<label>Varianten
<select id="detail-ai-model-variants" class="model-select">
<option value="1" selected>1 Bild</option>
<option value="2">2 Bilder</option>
<option value="3">3 Bilder</option>
</select>
</label>
<button id="detail-ai-model-prompt-btn" class="small-btn ghost" type="button">Prompt…</button>
<button id="detail-ai-model-btn" class="small-btn" type="button">Bild erzeugen</button>
</div>
<div id="detail-ai-model-status" class="ai-model-status">Nutzt die aktuellen Listing-Fotos als Vorlage.</div>
<div id="detail-ai-model-grid" class="ai-model-grid hidden"></div>
<div class="ai-generation-history-wrap">
<div class="ai-generation-history-head"><strong>Historie</strong><span>Vorherige Generierungen schnell übernehmen</span></div>
<div id="detail-ai-model-history" class="ai-generation-history"></div>
</div>
</section>
</aside>
<main class="listing-content-editor">
<section class="listing-editor-section">
<div class="listing-section-heading"><div><span>02</span><h3>Listing-Daten</h3></div></div>
<div class="listing-form-grid three">
<label>Plattform<select id="detail-platform">${detailPlatformOptions(l.platform)}</select></label>
<label>AI-Provider<select id="detail-ai-provider">${detailProviderOptions(l.ai_provider)}</select></label>
<label>Sprache<select id="detail-language">${detailLanguageOptions(l.language || 'de')}</select></label>
<label>SKU<input id="detail-sku" value="${escapeHtml(skuVal)}"></label>
<label>Status<select id="detail-status"><option value="active"${(l.status || 'active') === 'active' ? ' selected' : ''}>Aktiv</option><option value="sold"${l.status === 'sold' ? ' selected' : ''}>Verkauft</option><option value="reserved"${l.status === 'reserved' ? ' selected' : ''}>Reserviert</option></select></label>
<label>Lagerort<input id="detail-storage-location" value="${escapeHtml(l.storage_location || '')}" placeholder="z. B. Regal A2"></label>
</div>
<label class="listing-full-field">Titel <span id="detail-title-count">${String(l.title || '').length} / 120</span><input id="detail-title" maxlength="120" value="${escapeHtml(l.title || '')}"></label>
<label class="listing-full-field">Tags / Hashtags<input id="detail-tags" value="${escapeHtml(tags)}" placeholder="Tags durch Komma trennen"></label>
</section>
<section class="listing-editor-section listing-description-workbench">
<div class="listing-section-heading"><div><span>03</span><h3>Beschreibung</h3></div><b id="detail-description-count">0 Zeichen</b></div>
<div class="detail-description-pane detail-wysiwyg-pane"><div class="detail-pane-head"><strong>WYSIWYG</strong><span>Formatierte Bearbeitung</span></div><div id="detail-description-editor"></div></div>
<div class="detail-platform-workbench">
<div class="html-view-toolbar detail-html-toolbar">
<div class="html-view-tabs" role="tablist" aria-label="Plattformdarstellung">
<button class="html-view-tab is-active" type="button" data-html-scope="detail" data-html-view="preview">Plattformvorschau</button>
<button class="html-view-tab" type="button" data-html-scope="detail" data-html-view="code">HTML-Code</button>
</div>
<span id="detail-html-sync-status">Synchron</span>
</div>
<div class="html-switch-stage detail-html-switch-stage">
<div id="detail-preview-view" class="html-switch-view is-active" data-html-panel="preview"><iframe id="detail-html-preview" class="detail-html-preview" sandbox title="Plattformvorschau"></iframe></div>
<div id="detail-code-view" class="html-switch-view" data-html-panel="code"><textarea id="detail-html-source" spellcheck="false" aria-label="HTML-Code der Beschreibung"></textarea></div>
</div>
</div>
</section>
<section class="listing-editor-section">
<div class="listing-section-heading"><div><span>04</span><h3>Eigenschaften &amp; Preis</h3></div></div>
<div class="listing-form-grid two">
<label>Marke<input id="detail-brand" value="${escapeHtml(l.brand || '')}"></label>
<label>Kategorie<div id="detail-category-picker" class="cat-picker"></div></label>
<label>Größe<input id="detail-size" value="${escapeHtml(l.size || '')}"></label>
<label>Farbe<input id="detail-color" value="${escapeHtml(l.color || '')}"></label>
<label>Zustand<input id="detail-condition" value="${escapeHtml(l.condition || '')}"></label>
<label>Preis (€)<input type="number" id="detail-price" step="0.01" min="0" value="${l.price ?? ''}"></label>
</div>
<label class="listing-full-field">Verkäufernotizen<textarea id="detail-seller-notes" rows="3">${escapeHtml(l.seller_notes || '')}</textarea></label>
</section>
</main>
<aside class="listing-context-panel">
<section class="listing-context-card">
<h3>Artikelidentität</h3>
<canvas id="detail-qr-canvas"></canvas>
<strong id="detail-sku-display">${escapeHtml(skuVal)}</strong>
</section>
<section class="listing-context-card">
<h3>HTML-Aktionen</h3>
<button id="detail-copy-editor-html" class="vd-button full" type="button">Editor-HTML kopieren</button>
<button id="detail-copy-platform-html" class="vd-button full" type="button">Plattform-HTML kopieren</button>
<button id="detail-clean-html" class="vd-button full" type="button">HTML bereinigen</button>
</section>
<section class="listing-context-card">
<h3>Ausgabe</h3>
<button id="detail-print-label" class="vd-button full" type="button">Etikett erstellen</button>
<button id="detail-print-sheet" class="vd-button full" type="button">Datenblatt erstellen</button>
</section>
<section class="listing-context-card danger-zone">
<h3>Gefahrenzone</h3>
<button id="detail-delete" class="vd-button full danger" type="button">In Papierkorb verschieben</button>
</section>
</aside>
</div>
</div>`;
const lockResult = await acquireListingLock(numericId);
if (!userCan('listings.edit') || lockResult.readOnly) {
document.querySelectorAll('#history-detail input, #history-detail textarea, #history-detail select').forEach(element => { element.disabled = true; });
document.getElementById('detail-save')?.setAttribute('disabled', 'disabled');
if (!lockResult.lock && !userCan('listings.edit')) showListingLockBanner({ user_name: 'Deine Rolle erlaubt nur Lesezugriff' }, false);
}
editors.detail = initQuill('#detail-description-editor', 'Beschreibung...');
const detailReadOnly = !userCan('listings.edit') || lockResult.readOnly;
if (detailReadOnly) editors.detail?.enable?.(false);
const initialHtml = l.description_html ? sanitizeRichHtmlClient(l.description_html) : plainTextToRichHtml(l.description || '');
htmlSyncState.detailLock = true;
setEditorHtml(editors.detail, initialHtml);
htmlSyncState.detailLock = false;
document.getElementById('detail-html-source').value = getEditorHtml(editors.detail);
document.getElementById('detail-description-count').textContent = `${editors.detail ? editors.detail.getText().trim().length : 0} Zeichen`;
detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', l.platform, l.category || null);
setupDetailHtmlSync();
bindHtmlViewTabs('detail');
setupDetailPhotoActions();
if (detailReadOnly) {
const mutationButtons = [
'detail-save', 'detail-delete', 'detail-add-photo', 'detail-mobile-photo',
'detail-edit-photo', 'detail-set-cover', 'detail-remove-photo',
'detail-ai-model-prompt-btn', 'detail-ai-model-btn', 'detail-clean-html',
];
mutationButtons.forEach(id => {
const element = document.getElementById(id);
if (element) { element.disabled = true; element.setAttribute('aria-disabled', 'true'); }
});
}
document.getElementById('detail-ai-model-btn')?.addEventListener('click', runDetailAiModelPhotos);
document.getElementById('detail-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('detail'));
document.getElementById('detail-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('detail'));
updateAiModeControls('detail');
renderDetailPhotos();
renderAiModelGrid('detail', []);
addQuillTooltips();
refreshDetailPlatformPreview();
loadDetailAiModelPhotos(numericId);
loadDetailAiModelGenerations(numericId);
if (typeof QRious !== 'undefined') {
new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' });
}
const refreshFields = ['detail-title', 'detail-tags', 'detail-brand', 'detail-size', 'detail-color', 'detail-condition'];
refreshFields.forEach(fieldId => document.getElementById(fieldId)?.addEventListener('input', refreshDetailPlatformPreview));
document.getElementById('detail-title')?.addEventListener('input', event => {
document.getElementById('detail-title-count').textContent = `${event.target.value.length} / 120`;
});
document.getElementById('detail-platform')?.addEventListener('change', event => {
const currentCategory = detailEditorState.categoryPicker?.getValue?.() || null;
detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', event.target.value, currentCategory);
refreshDetailPlatformPreview();
});
document.getElementById('detail-sku')?.addEventListener('input', event => { document.getElementById('detail-sku-display').textContent = event.target.value || 'Ohne SKU'; });
document.getElementById('detail-copy-editor-html').addEventListener('click', () => { navigator.clipboard.writeText(getEditorHtml(editors.detail)); toast('Editor-HTML kopiert'); });
document.getElementById('detail-copy-platform-html').addEventListener('click', () => { navigator.clipboard.writeText(detailEditorState.platformHtml || getEditorHtml(editors.detail)); toast('Plattform-HTML kopiert'); });
document.getElementById('detail-clean-html').addEventListener('click', () => {
const source = document.getElementById('detail-html-source');
const safe = sanitizeRichHtmlClient(source.value);
source.value = safe;
htmlSyncState.detailLock = true;
setEditorHtml(editors.detail, safe);
htmlSyncState.detailLock = false;
refreshDetailPlatformPreview();
toast('HTML bereinigt');
});
document.getElementById('detail-print-label').addEventListener('click', () => printLabels([{ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, price: parseFloat(document.getElementById('detail-price').value) || null }]));
document.getElementById('detail-print-sheet').addEventListener('click', () => printDatasheet({ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, description: editors.detail?.getText().trim() || '', price: parseFloat(document.getElementById('detail-price').value) || null }));
document.getElementById('detail-save').addEventListener('click', async () => {
const descriptionHtml = getEditorHtml(editors.detail);
const update = {
platform: document.getElementById('detail-platform').value,
ai_provider: document.getElementById('detail-ai-provider').value,
language: document.getElementById('detail-language').value,
title: document.getElementById('detail-title').value,
description: richHtmlToText(descriptionHtml),
description_html: descriptionHtml,
tags: document.getElementById('detail-tags').value.split(',').map(tag => tag.trim()).filter(Boolean),
photos: [...detailEditorState.photos],
seller_notes: document.getElementById('detail-seller-notes').value || null,
status: document.getElementById('detail-status').value,
sku: document.getElementById('detail-sku').value || null,
brand: document.getElementById('detail-brand').value || null,
category: detailEditorState.categoryPicker?.getValue?.() || null,
size: document.getElementById('detail-size').value || null,
color: document.getElementById('detail-color').value || null,
condition: document.getElementById('detail-condition').value || null,
price: parseFloat(document.getElementById('detail-price').value) || null,
storage_location: document.getElementById('detail-storage-location').value || null,
};
try {
const saved = await api('PUT', `/api/listings/${numericId}`, { ...update, _edit_lock: listingEditLock.listingId === numericId ? listingEditLock.token : null });
detailEditorState.listing = saved;
toast('Alle Änderungen gespeichert');
loadHistory();
} catch (err) { toast('Fehler: ' + err.message); }
});
document.getElementById('detail-copy-all').addEventListener('click', () => {
const content = `${document.getElementById('detail-title').value}\n\n${editors.detail?.getText().trim() || ''}\n\n${document.getElementById('detail-tags').value}`;
navigator.clipboard.writeText(content);
toast('Alles kopiert!');
});
document.getElementById('detail-delete').addEventListener('click', async () => {
if (!confirm('Listing in den Papierkorb verschieben?')) return;
await api('DELETE', `/api/listings/${numericId}`);
toast('In Papierkorb verschoben');
updateTrashBadge();
document.getElementById('back-to-list').click();
loadHistory();
});
setHistoryDetailMode(true);
document.getElementById('history-detail')?.scrollIntoView({ block: 'start' });
}
// --- Templates ---
function setupTemplates() {
document.getElementById('add-template-btn').addEventListener('click', () => {
state.editingTemplateId = null;
document.getElementById('template-form-title').textContent = 'Neue Vorlage';
document.getElementById('tpl-name').value = '';
document.getElementById('tpl-platform').value = '';
document.getElementById('tpl-language').value = 'de';
if (editors.tplNotes) editors.tplNotes.setText('');
document.getElementById('tpl-tags').value = '';
document.getElementById('template-form').classList.remove('hidden');
});
document.getElementById('cancel-template-btn').addEventListener('click', () => {
document.getElementById('template-form').classList.add('hidden');
});
document.getElementById('save-template-btn').addEventListener('click', async () => {
const data = {
name: document.getElementById('tpl-name').value,
platform: document.getElementById('tpl-platform').value || null,
language: document.getElementById('tpl-language').value,
seller_notes: editors.tplNotes ? editors.tplNotes.getText().trim() : '',
default_tags: document.getElementById('tpl-tags').value.split(',').map(t => t.trim()).filter(Boolean),
};
if (!data.name) { toast('Name ist Pflichtfeld'); return; }
try {
if (state.editingTemplateId) {
await api('PUT', `/api/templates/${state.editingTemplateId}`, data);
} else {
await api('POST', '/api/templates', data);
}
state.templates = await api('GET', '/api/templates');
renderTemplateList();
updateTemplateDropdown();
document.getElementById('template-form').classList.add('hidden');
toast('Vorlage gespeichert!');
} catch (err) { toast('Fehler: ' + err.message); }
});
}
function renderTemplateList() {
const c = document.getElementById('template-list');
if (!state.templates.length) { c.innerHTML = '<div class="empty-state">Keine Vorlagen vorhanden</div>'; return; }
c.innerHTML = state.templates.map(t => {
const pName = state.platforms.find(p => p.id === t.platform)?.name || 'Alle Plattformen';
return `<div class="template-item">
<div class="tpl-info">
<div class="tpl-name">${escapeHtml(t.name)}</div>
<div class="tpl-meta">${pName} · ${t.language.toUpperCase()}</div>
</div>
<div class="tpl-actions">
<button class="small-btn tpl-edit" data-id="${t.id}">Bearbeiten</button>
<button class="small-btn tpl-delete" data-id="${t.id}">Löschen</button>
</div>
</div>`;
}).join('');
c.querySelectorAll('.tpl-edit').forEach(btn => {
btn.addEventListener('click', () => {
const t = state.templates.find(x => x.id === parseInt(btn.dataset.id));
if (!t) return;
state.editingTemplateId = t.id;
document.getElementById('template-form-title').textContent = 'Vorlage bearbeiten';
document.getElementById('tpl-name').value = t.name;
document.getElementById('tpl-platform').value = t.platform || '';
document.getElementById('tpl-language').value = t.language || 'de';
if (editors.tplNotes) editors.tplNotes.setText(t.seller_notes || '');
document.getElementById('tpl-tags').value = (t.default_tags || []).join(', ');
document.getElementById('template-form').classList.remove('hidden');
});
});
c.querySelectorAll('.tpl-delete').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Vorlage löschen?')) return;
await api('DELETE', `/api/templates/${btn.dataset.id}`);
state.templates = await api('GET', '/api/templates');
renderTemplateList();
updateTemplateDropdown();
toast('Vorlage gelöscht');
});
});
}
function updateTemplateDropdown() {
const sel = document.getElementById('template-select');
sel.innerHTML = '<option value="">Keine Vorlage</option>' +
state.templates.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('');
}
function populateTemplateSelects(platforms) {
const sel = document.getElementById('tpl-platform');
sel.innerHTML = '<option value="">Alle</option>' +
platforms.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
}
// --- Inventory / Storage ---
async function loadInventory() {
const locations = await api('GET', '/api/inventory/locations');
const unassigned = await api('GET', '/api/inventory/unassigned');
const statsEl = document.getElementById('inv-stats');
const totalItems = locations.reduce((s, l) => s + l.count, 0) + unassigned.length;
const totalActive = locations.reduce((s, l) => s + l.active, 0) + unassigned.filter(x => x.status === 'active').length;
const totalValue = locations.reduce((s, l) => s + l.total_value, 0) + unassigned.filter(x => x.status === 'active').reduce((s, x) => s + (x.price || 0), 0);
statsEl.innerHTML = `
<div class="inv-stat-card"><div class="inv-stat-value">${totalItems}</div><div class="inv-stat-label">Artikel gesamt</div></div>
<div class="inv-stat-card"><div class="inv-stat-value">${locations.length}</div><div class="inv-stat-label">Lagerorte</div></div>
<div class="inv-stat-card"><div class="inv-stat-value">${totalActive}</div><div class="inv-stat-label">Aktive Artikel</div></div>
<div class="inv-stat-card"><div class="inv-stat-value">${totalValue.toFixed(0)} €</div><div class="inv-stat-label">Lagerwert</div></div>`;
const locsEl = document.getElementById('inv-locations');
let html = '';
for (const loc of locations) {
html += `<div class="inv-location-card" data-location="${escapeHtml(loc.storage_location)}">
<div class="inv-loc-header">
<div class="inv-loc-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></div>
<div class="inv-loc-name">${escapeHtml(loc.storage_location)}</div>
</div>
<div class="inv-loc-stats">
<span class="inv-loc-count">${loc.count} Artikel</span>
<span class="inv-loc-active">${loc.active} aktiv</span>
${loc.sold ? `<span class="inv-loc-sold">${loc.sold} verkauft</span>` : ''}
${loc.reserved ? `<span class="inv-loc-reserved">${loc.reserved} reserviert</span>` : ''}
</div>
<div class="inv-loc-value">${loc.total_value.toFixed(0)} €</div>
</div>`;
}
if (unassigned.length) {
html += `<div class="inv-location-card inv-unassigned" data-location="">
<div class="inv-loc-header">
<div class="inv-loc-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
<div class="inv-loc-name">Ohne Lagerort</div>
</div>
<div class="inv-loc-stats"><span class="inv-loc-count">${unassigned.length} Artikel</span></div>
</div>`;
}
if (!html) html = '<div class="empty-state">Noch keine Lagerorte vorhanden. Weise Artikeln einen Lagerort zu.</div>';
locsEl.innerHTML = html;
document.getElementById('inv-detail').classList.add('hidden');
locsEl.classList.remove('hidden');
locsEl.querySelectorAll('.inv-location-card').forEach(card => {
card.addEventListener('click', () => showInventoryDetail(card.dataset.location));
});
document.getElementById('inv-add-location-btn').onclick = () => {
const name = prompt('Neuer Lagerort (z.B. Regal A1, Karton 5, Schrank oben):');
if (!name?.trim()) return;
toast(`Lagerort "${name.trim()}" angelegt. Weise jetzt Artikel zu.`);
loadInventory();
};
}
async function showInventoryDetail(location) {
const items = location
? await api('GET', `/api/inventory/location/${encodeURIComponent(location)}`)
: await api('GET', '/api/inventory/unassigned');
document.getElementById('inv-locations').classList.add('hidden');
const detail = document.getElementById('inv-detail');
detail.classList.remove('hidden');
document.getElementById('inv-detail-title').textContent = location || 'Ohne Lagerort';
const list = document.getElementById('inv-detail-list');
if (!items.length) {
list.innerHTML = '<div class="empty-state">Keine Artikel an diesem Lagerort</div>';
} else {
list.innerHTML = items.map(l => {
const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform;
const statusCls = l.status === 'sold' ? 'sold' : l.status === 'reserved' ? 'reserved' : '';
return `<div class="history-item">
<input type="checkbox" class="inv-item-check" data-id="${l.id}">
<div class="listing-info">
<div class="listing-title">${l.sku ? `<span class="sku-badge">${escapeHtml(l.sku)}</span>` : ''} ${escapeHtml(l.title || 'Ohne Titel')}</div>
<div class="listing-meta">${pName} · ${l.price ? l.price.toFixed(2) + ' €' : ''} · <span class="status-dot ${statusCls}">${l.status || 'active'}</span></div>
</div>
</div>`;
}).join('');
}
document.getElementById('inv-back').onclick = () => loadInventory();
document.getElementById('inv-rename-btn').onclick = async () => {
if (!location) return toast('Ohne Lagerort kann nicht umbenannt werden');
const newName = prompt('Lagerort umbenennen:', location);
if (!newName?.trim() || newName.trim() === location) return;
const ids = items.map(l => l.id);
await api('PUT', '/api/inventory/bulk-location', { ids, location: newName.trim() });
toast(`Umbenannt zu "${newName.trim()}"`);
loadInventory();
};
document.getElementById('inv-move-btn').onclick = async () => {
const checked = [...detail.querySelectorAll('.inv-item-check:checked')].map(c => parseInt(c.dataset.id));
if (!checked.length) return toast('Bitte Artikel auswählen');
const dest = prompt('Ziel-Lagerort:');
if (!dest?.trim()) return;
await api('PUT', '/api/inventory/bulk-location', { ids: checked, location: dest.trim() });
toast(`${checked.length} Artikel verschoben nach "${dest.trim()}"`);
loadInventory();
};
}
// --- Fee Calculator ---
function setupFeeCalculator() {
document.getElementById('fee-price').addEventListener('input', debounce(updateFeeCalculator, 200));
}
async function updateFeeCalculator() {
const price = parseFloat(document.getElementById('fee-price').value) || 0;
if (price <= 0) { document.getElementById('fee-results').innerHTML = ''; return; }
try {
const fees = await api('GET', `/api/fees?price=${price}`);
const c = document.getElementById('fee-results');
c.innerHTML = Object.entries(fees).map(([id, f]) => {
const name = state.platforms.find(p => p.id === id)?.name || id;
return `<div class="fee-card${Number(f.fees) === 0 ? ' is-free' : ''}">
<div>
<div class="fee-platform">${escapeHtml(name)} <span>${f.seller_type === 'privat' ? 'Privat' : 'Shop'}</span></div>
<div class="fee-breakdown">${escapeHtml(f.breakdown || '')}</div>
${f.note ? `<div class="fee-note">${escapeHtml(f.note)}</div>` : ''}
</div>
<div class="fee-amounts">
<div class="fee-net-amount">${Number(f.net || 0).toFixed(2)} €</div>
<div class="fee-deducted">${Number(f.fees) === 0 ? 'Keine Verkäufergebühr' : `-${Number(f.fees).toFixed(2)} € Gebühren`}</div>
</div>
</div>`;
}).join('');
} catch {}
}
// --- Publish Center ---
const publishState = {
selected: new Set(),
listings: [],
allFiltered: [],
publishStatus: {},
queue: [],
scheduled: [],
workflow: null,
integrations: { ebay: null, etsy: null },
selectedId: null,
view: 'smart',
renderToken: 0,
smartCopyToken: 0,
};
function setupPublish() {
const search = document.getElementById('publish-search');
if (!search) return;
search.addEventListener('input', debounce(loadPublishList, 250));
document.getElementById('publish-filter-platform')?.addEventListener('change', loadPublishList);
document.getElementById('publish-filter-status')?.addEventListener('change', loadPublishList);
document.getElementById('publish-sort')?.addEventListener('change', () => {
sortPublishListings();
renderPublishList();
});
document.getElementById('publish-select-all')?.addEventListener('change', event => {
for (const listing of publishState.listings) {
if (event.target.checked) publishState.selected.add(listing.id);
else publishState.selected.delete(listing.id);
}
renderPublishList();
updatePublishToolbar();
});
const platformSelect = document.getElementById('publish-filter-platform');
platformSelect.innerHTML = '<option value="">Alle Plattformen</option>' +
state.platforms.map(platform => `<option value="${platform.id}">${escapeHtml(platform.name)}</option>`).join('');
document.querySelectorAll('[data-publish-view]').forEach(button => {
button.addEventListener('click', () => {
setPublishView(button.dataset.publishView);
if (button.dataset.publishView === 'smart' && selectedPublishListing()) openSmartCopyModal(selectedPublishListing());
});
});
document.querySelectorAll('[data-status-target]').forEach(button => {
button.addEventListener('click', () => setPublishView(button.dataset.statusTarget));
});
document.getElementById('publish-refresh')?.addEventListener('click', loadPublishList);
document.getElementById('publish-status-refresh')?.addEventListener('click', loadPublishList);
document.getElementById('publish-batch-copy')?.addEventListener('click', batchSmartCopy);
document.getElementById('publish-batch-etsy')?.addEventListener('click', batchEtsyPublish);
document.getElementById('publish-batch-ebay')?.addEventListener('click', batchEbayAdd);
document.getElementById('publish-add-selected-ebay')?.addEventListener('click', batchEbayAdd);
document.getElementById('publish-batch-schedule')?.addEventListener('click', openBatchSchedule);
document.getElementById('publish-download-photos')?.addEventListener('click', batchDownloadPhotos);
document.getElementById('publish-ebay-queue-btn')?.addEventListener('click', openEbayQueue);
document.getElementById('publish-schedule-overview')?.addEventListener('click', openScheduleOverview);
document.getElementById('publish-ebay-queue-process')?.addEventListener('click', processEbayQueue);
document.getElementById('publish-ebay-queue-retry')?.addEventListener('click', retryEbayQueue);
document.getElementById('schedule-modal-close')?.addEventListener('click', closePublishSchedule);
document.getElementById('schedule-cancel')?.addEventListener('click', closePublishSchedule);
document.getElementById('schedule-modal')?.addEventListener('click', event => {
if (event.target === event.currentTarget) closePublishSchedule();
});
document.querySelectorAll('[data-smart-copy-close]').forEach(button => button.addEventListener('click', closeSmartCopyModal));
document.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
if (isSmartCopyModalOpen()) closeSmartCopyModal();
if (!document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) closeFluxPromptLab();
});
}
async function publishSafeGet(url, fallback, attempts = 1) {
let lastError = null;
for (let attempt = 0; attempt < attempts; attempt++) {
try { return await api('GET', url); }
catch (error) {
lastError = error;
if (attempt < attempts - 1) await new Promise(resolve => setTimeout(resolve, 220 * (attempt + 1)));
}
}
if (fallback === PUBLISH_REQUIRED) throw lastError || new Error('Daten konnten nicht geladen werden');
return fallback;
}
const PUBLISH_REQUIRED = Symbol('publish-required');
async function loadPublishListLegacy() {
const search = document.getElementById('publish-search');
const listContainer = document.getElementById('publish-list');
if (!search || !listContainer) return;
const renderToken = ++publishState.renderToken;
const q = search.value.trim();
const platform = document.getElementById('publish-filter-platform')?.value || '';
const status = document.getElementById('publish-filter-status')?.value || '';
const params = new URLSearchParams();
if (q) params.set('q', q);
if (platform) params.set('platform', platform);
if (status) params.set('status', status);
const refreshButtons = [document.getElementById('publish-refresh'), document.getElementById('publish-status-refresh')];
const refreshLabels = refreshButtons.map(button => button?.textContent || '');
refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.textContent = index === 0 ? 'Wird geladen …' : '…'; });
if (!publishState.listings.length) listContainer.innerHTML = '<div class="publish-loading-state"><span></span><strong>Artikel werden geladen…</strong></div>';
try {
const query = params.toString();
const [listings, queue, scheduled, workflow, ebayStatus, etsyStatus] = await Promise.all([
publishSafeGet(`/api/listings${query ? `?${query}` : ''}`, PUBLISH_REQUIRED, 3),
publishSafeGet('/api/ebay/queue', [], 2),
publishSafeGet('/api/schedule', [], 2),
publishSafeGet('/api/dashboard/workflow?limit=4', null, 2),
publishSafeGet('/api/ebay/status', { connected: false, hasCredentials: false }, 2),
publishSafeGet('/api/etsy/status', { connected: false, hasApiKey: false }, 2),
]);
if (renderToken !== publishState.renderToken) return;
publishState.allFiltered = Array.isArray(listings) ? listings : [];
publishState.listings = [...publishState.allFiltered];
publishState.queue = Array.isArray(queue) ? queue : [];
publishState.scheduled = Array.isArray(scheduled) ? scheduled : [];
publishState.workflow = workflow;
publishState.integrations = { ebay: ebayStatus, etsy: etsyStatus };
const ids = publishState.listings.map(listing => listing.id);
publishState.publishStatus = ids.length
? await publishSafeGet(`/api/publish/status?ids=${ids.join(',')}`, {}, 2)
: {};
if (renderToken !== publishState.renderToken) return;
sortPublishListings();
const visibleIds = new Set(publishState.listings.map(listing => listing.id));
if (!publishState.selectedId || !visibleIds.has(publishState.selectedId)) {
const selectedVisible = [...publishState.selected].find(id => visibleIds.has(id));
publishState.selectedId = selectedVisible || publishState.listings[0]?.id || null;
}
renderPublishList();
renderPublishWorkspace();
renderPublishStatusRail();
updatePublishToolbar();
} catch (error) {
if (renderToken !== publishState.renderToken) return;
toast(`Publish-Center konnte nicht geladen werden: ${error.message}`);
listContainer.innerHTML = `<div class="publish-empty-state"><strong>Artikel konnten nicht geladen werden</strong><span>${escapeHtml(error.message)}</span><button type="button" class="vd-button compact" data-publish-retry>Erneut laden</button></div>`;
listContainer.querySelector('[data-publish-retry]')?.addEventListener('click', loadPublishList);
} finally {
if (renderToken === publishState.renderToken) refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = false; button.removeAttribute('aria-busy'); button.textContent = refreshLabels[index]; });
}
}
function sortPublishListings() {
const sort = document.getElementById('publish-sort')?.value || 'newest';
const byDate = value => new Date(value || 0).getTime() || 0;
publishState.listings.sort((a, b) => {
if (sort === 'oldest') return byDate(a.created_at) - byDate(b.created_at);
if (sort === 'price-desc') return (Number(b.price) || 0) - (Number(a.price) || 0);
if (sort === 'price-asc') return (Number(a.price) || 0) - (Number(b.price) || 0);
if (sort === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'de');
return byDate(b.created_at) - byDate(a.created_at);
});
}
function publishLatestStatus(listing) {
const statuses = publishState.publishStatus[listing.id] || {};
const entries = Object.entries(statuses);
if (!entries.length) return { label: 'Vorbereitet', status: 'prepared', platform: listing.platform };
const priority = { failed: 0, uploading: 1, pending: 2, copied: 3, published: 4 };
entries.sort((a, b) => (priority[a[1]] ?? 9) - (priority[b[1]] ?? 9));
const [platform, status] = entries[0];
const labels = { failed: 'Fehlgeschlagen', uploading: 'In Bearbeitung', pending: 'Ausstehend', copied: 'Kopiert', published: 'Live' };
return { label: labels[status] || status, status, platform };
}
function renderPublishList() {
const container = document.getElementById('publish-list');
if (!container) return;
document.getElementById('publish-list-count').textContent = publishState.listings.length;
if (!publishState.listings.length) {
container.innerHTML = '<div class="publish-empty-state"><strong>Keine Artikel gefunden</strong><span>Ändere Suche oder Filter.</span></div>';
updatePublishToolbar();
return;
}
container.innerHTML = publishState.listings.map(listing => {
const photo = listing.photos?.[0];
const status = publishLatestStatus(listing);
const selected = listing.id === publishState.selectedId;
const checked = publishState.selected.has(listing.id);
return `<article class="publish-list-card${selected ? ' is-active' : ''}" data-publish-listing-id="${listing.id}">
<label class="publish-card-check" title="Auswählen"><input type="checkbox" data-publish-check="${listing.id}" ${checked ? 'checked' : ''}></label>
<div class="publish-card-media">${photo ? `<img src="${photoUrl(photo)}" alt="">` : '<span>Kein Bild</span>'}</div>
<div class="publish-card-copy">
<strong>${escapeHtml(listing.title || 'Ohne Titel')}</strong>
<span class="publish-card-sku">${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)}</span>
<div class="publish-card-meta"><span>${escapeHtml(platformLabel(listing.platform))}</span><b>${formatCurrency(listing.price) || 'Preis fehlt'}</b></div>
</div>
<span class="publish-card-status status-${escapeHtml(status.status)}">${escapeHtml(status.label)}</span>
</article>`;
}).join('');
container.querySelectorAll('[data-publish-listing-id]').forEach(card => {
card.addEventListener('click', event => {
if (event.target.closest('input,button,a,label')) return;
publishState.selectedId = Number(card.dataset.publishListingId);
renderPublishList();
renderPublishWorkspace();
});
});
container.querySelectorAll('[data-publish-check]').forEach(checkbox => {
checkbox.addEventListener('change', event => {
const id = Number(event.target.dataset.publishCheck);
if (event.target.checked) publishState.selected.add(id);
else publishState.selected.delete(id);
if (!publishState.selectedId) publishState.selectedId = id;
updatePublishToolbar();
});
});
}
function updatePublishToolbar() {
const count = publishState.selected.size;
const selectedLabel = document.getElementById('publish-selected-count');
if (selectedLabel) selectedLabel.textContent = `${count} ausgewählt`;
['publish-batch-copy', 'publish-batch-etsy', 'publish-batch-ebay', 'publish-batch-schedule', 'publish-download-photos', 'publish-add-selected-ebay']
.forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; });
const visible = publishState.listings;
const selectedVisible = visible.filter(listing => publishState.selected.has(listing.id)).length;
const selectAll = document.getElementById('publish-select-all');
if (selectAll) {
selectAll.checked = visible.length > 0 && selectedVisible === visible.length;
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
}
}
function setPublishViewLegacy(view) {
publishState.view = ['smart', 'direct', 'queue', 'scheduled'].includes(view) ? view : 'smart';
document.querySelectorAll('[data-publish-view]').forEach(button => {
const active = button.dataset.publishView === publishState.view;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
renderPublishWorkspace();
}
function selectedPublishListing() {
return publishState.listings.find(listing => listing.id === publishState.selectedId)
|| publishState.allFiltered.find(listing => listing.id === publishState.selectedId)
|| null;
}
function renderPublishWorkspaceLegacy() {
if (publishState.view === 'queue') { renderQueueWorkspace(); return; }
if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; }
const listing = selectedPublishListing();
const workspace = document.getElementById('publish-workspace');
if (!workspace) return;
if (!listing) {
workspace.innerHTML = `<div class="publish-workspace-empty"><span>↗</span><h3>Artikel auswählen</h3><p>Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.</p></div>`;
return;
}
if (publishState.view === 'direct') renderDirectPublishWorkspace(listing);
else renderSmartCopyLauncher(listing);
}
function renderSmartCopyLauncher(listing) {
const workspace = document.getElementById('publish-workspace');
const target = publishPlatformTarget(listing.platform);
const score = publishReadinessScore(listing);
const tags = Array.isArray(listing.tags) ? listing.tags : String(listing.tags || '').split(',').map(value => value.trim()).filter(Boolean);
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit')}
<div class="smart-copy-center">
<section class="smart-copy-center-main">
<div class="smart-copy-center-heading"><div><span class="publish-panel-kicker">Verkaufsdaten</span><h3>${escapeHtml(listing.title || 'Ohne Titel')}</h3></div><button type="button" class="vd-button primary" data-open-smart-copy-modal>${window.VendooIcons?.svg('external-link', { size: 17 }) || ''} Smart Copy öffnen</button></div>
<div class="smart-copy-center-description">${escapeHtml(listing.description || 'Noch keine Beschreibung vorhanden.').replace(/\n/g, '<br>')}</div>
<div class="smart-copy-center-tags">${tags.length ? tags.slice(0, 12).map(tag => `<span>${escapeHtml(tag)}</span>`).join('') : '<span>Keine Tags</span>'}</div>
<div class="smart-copy-center-meta">
<div><span>Plattform</span><strong>${escapeHtml(target.name)}</strong></div><div><span>Preis</span><strong>${escapeHtml(formatCurrency(listing.price) || 'Nicht gesetzt')}</strong></div>
<div><span>Zustand</span><strong>${escapeHtml(listing.condition || 'Nicht gesetzt')}</strong></div><div><span>Marke</span><strong>${escapeHtml(listing.brand || 'Nicht gesetzt')}</strong></div>
<div><span>Kategorie</span><strong>${escapeHtml(listing.category || 'Nicht gesetzt')}</strong></div><div><span>Größe</span><strong>${escapeHtml(listing.size || 'Nicht gesetzt')}</strong></div>
</div>
</section>
<aside class="smart-copy-center-side">
<div class="smart-copy-launcher-score"><strong>${score}%</strong><span>Artikel vollständig</span></div>
<section class="publish-photo-section"><div class="publish-field-head"><label>Fotos</label><span>${listing.photos?.length || 0}</span></div>${publishPhotoGrid(listing.photos || [], 8)}</section>
<p>Der Smart-Copy-Link öffnet das vollständige Popup mit allen Kopierfeldern, Fotos und Plattformaktionen.</p>
</aside>
</div>`;
bindPublishWorkspaceNavigation(workspace);
workspace.querySelector('[data-open-smart-copy-modal]')?.addEventListener('click', () => openSmartCopyModal(listing));
window.VendooIcons?.render(workspace);
}
function isSmartCopyModalOpen() {
return !document.getElementById('smart-copy-modal')?.classList.contains('hidden');
}
function closeSmartCopyModal() {
const modal = document.getElementById('smart-copy-modal');
modal?.classList.add('hidden');
modal?.setAttribute('aria-hidden', 'true');
document.getElementById('smart-copy-modal-content')?.replaceChildren();
if (document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) document.body.classList.remove('modal-open');
}
function openSmartCopyModal(listing = selectedPublishListing()) {
if (!listing) return toast('Bitte zuerst ein Artikel auswählen.');
const modal = document.getElementById('smart-copy-modal');
modal?.classList.remove('hidden');
modal?.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
renderSmartCopyWorkspace(listing);
}
function publishWorkspaceHeader(listing, kicker, stateLabel) {
const photo = listing.photos?.[0];
return `<header class="publish-workspace-header">
<div class="publish-workspace-product">
<div class="publish-workspace-thumb">${photo ? `<img src="${photoUrl(photo)}" alt="">` : '<span>Kein Bild</span>'}</div>
<div><span class="publish-panel-kicker">${escapeHtml(kicker)}</span><h3>${escapeHtml(listing.title || 'Ohne Titel')}</h3><p>${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)} · ${escapeHtml(platformLabel(listing.platform))}</p></div>
</div>
<div class="publish-workspace-header-actions">
<span class="publish-workspace-state">${escapeHtml(stateLabel)}</span>
<button class="icon-button" type="button" data-publish-previous title="Vorheriger Artikel"></button>
<button class="icon-button" type="button" data-publish-next title="Nächster Artikel"></button>
</div>
</header>`;
}
function bindPublishWorkspaceNavigation(scope = document) {
const currentIndex = publishState.listings.findIndex(listing => listing.id === publishState.selectedId);
scope.querySelector('[data-publish-previous]')?.addEventListener('click', () => {
if (!publishState.listings.length) return;
const nextIndex = currentIndex <= 0 ? publishState.listings.length - 1 : currentIndex - 1;
publishState.selectedId = publishState.listings[nextIndex].id;
renderPublishList();
if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace();
});
scope.querySelector('[data-publish-next]')?.addEventListener('click', () => {
if (!publishState.listings.length) return;
const nextIndex = currentIndex >= publishState.listings.length - 1 ? 0 : currentIndex + 1;
publishState.selectedId = publishState.listings[nextIndex].id;
renderPublishList();
if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace();
});
}
function publishPlatformTarget(platform) {
return ({
vinted: { name: 'Vinted', url: 'https://www.vinted.de/items/new', direct: false },
'ebay-ka': { name: 'Kleinanzeigen', url: 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', direct: false },
'ebay-de': { name: 'eBay.de', url: 'https://www.ebay.de/sell/create', direct: true },
etsy: { name: 'Etsy', url: 'https://www.etsy.com/your/shops/me/tools/listings/create', direct: true },
})[platform] || { name: platformLabel(platform), url: null, direct: false };
}
function publishPhotoGrid(photos, limit = 6) {
if (!photos?.length) return '<div class="publish-no-photos">Keine Fotos hinterlegt</div>';
const visible = photos.slice(0, limit);
return `<div class="publish-photo-grid">${visible.map(photo => `<img src="${photoUrl(photo)}" alt="">`).join('')}${photos.length > limit ? `<span>+${photos.length - limit} weitere</span>` : ''}</div>`;
}
async function renderSmartCopyWorkspace(listing) {
const workspace = document.getElementById('smart-copy-modal-content');
if (!workspace) return;
const token = ++publishState.smartCopyToken;
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Wird vorbereitet')}<div class="publish-workspace-loading"><span></span><strong>Daten werden vorbereitet …</strong></div>`;
bindPublishWorkspaceNavigation(workspace);
try {
const data = await api('POST', '/api/publish/smart-copy', { listing_id: listing.id });
if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return;
const fields = data.fields || {};
const target = publishPlatformTarget(listing.platform);
const tags = Array.isArray(fields.tags) ? fields.tags.join(', ') : (fields.tags || '');
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit zum Kopieren')}
<div class="publish-smart-grid">
<div class="publish-editor-column">
<section class="publish-editor-section">
<div class="publish-field-head"><label>Titel</label><span>${String(fields.title || '').length} Zeichen</span></div>
<div class="publish-copy-field"><input id="publish-smart-title" value="${escapeHtml(fields.title || '')}" readonly><button type="button" data-copy-publish-field="publish-smart-title">Kopieren</button></div>
</section>
<section class="publish-editor-section">
<div class="publish-field-head"><label>Beschreibung</label><div><button type="button" data-copy-publish-description>Text kopieren</button><button type="button" data-copy-publish-html>HTML kopieren</button></div></div>
<textarea id="publish-smart-description" readonly>${escapeHtml(fields.description || '')}</textarea>
</section>
<section class="publish-editor-section">
<div class="publish-field-head"><label>Tags</label><span>${tags ? tags.split(',').filter(Boolean).length : 0}</span></div>
<div class="publish-copy-field"><input id="publish-smart-tags" value="${escapeHtml(tags)}" readonly><button type="button" data-copy-publish-field="publish-smart-tags">Kopieren</button></div>
</section>
<div class="publish-meta-form compact">
${publishReadonlyMeta('Preis', fields.price ? formatCurrency(fields.price) : 'Nicht gesetzt', 'publish-smart-price')}
${publishReadonlyMeta('Marke', fields.brand || '—', 'publish-smart-brand')}
${publishReadonlyMeta('Kategorie', fields.category || '—', 'publish-smart-category')}
${publishReadonlyMeta('Größe', fields.size || '—', 'publish-smart-size')}
${publishReadonlyMeta('Zustand', fields.condition || '—', 'publish-smart-condition')}
</div>
</div>
<aside class="publish-readiness-column">
<section class="publish-readiness-card">
<div class="publish-readiness-head"><div><span class="publish-panel-kicker">Zielplattform</span><h4>${escapeHtml(target.name)}</h4></div><span class="publish-platform-wordmark">${escapeHtml(platformLabel(listing.platform))}</span></div>
<ul>${publishReadinessItems(listing).map(item => `<li class="${item.ok ? 'is-ready' : 'is-missing'}"><span>${item.ok ? '✓' : '!'}</span>${escapeHtml(item.label)}</li>`).join('')}</ul>
<div class="publish-ready-message ${publishReadinessScore(listing) === 100 ? 'is-ready' : ''}"><strong>${publishReadinessScore(listing)}% vollständig</strong><small>${publishReadinessScore(listing) === 100 ? 'Alle Kerndaten sind vorhanden.' : 'Fehlende Angaben vor Veröffentlichung ergänzen.'}</small></div>
</section>
<section class="publish-photo-section"><div class="publish-field-head"><label>Fotos</label><span>${data.photos?.length || 0}</span></div>${publishPhotoGrid(data.photos || [])}<button type="button" class="vd-button compact full" data-download-smart-photos>Fotos speichern</button></section>
</aside>
</div>
<footer class="publish-workspace-footer">
<div><button type="button" class="vd-button" data-publish-edit-listing>Artikel bearbeiten</button><button type="button" class="vd-button" data-copy-publish-all>Alles kopieren</button></div>
<div>${target.url ? `<button type="button" class="vd-button" data-open-publish-platform>Plattform öffnen</button>` : ''}<button type="button" class="vd-button primary" data-mark-publish-live>Als veröffentlicht markieren</button></div>
</footer>`;
bindPublishWorkspaceNavigation(workspace);
bindSmartCopyActions(workspace, listing, data, target, fields, tags);
} catch (error) {
if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return;
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Fehler')}<div class="publish-workspace-error"><strong>Smart Copy konnte nicht vorbereitet werden</strong><p>${escapeHtml(error.message)}</p><button class="vd-button" type="button" data-retry-smart-copy>Erneut versuchen</button></div>`;
bindPublishWorkspaceNavigation(workspace);
workspace.querySelector('[data-retry-smart-copy]')?.addEventListener('click', () => renderSmartCopyWorkspace(listing));
}
}
function publishReadonlyMeta(label, value, id) {
return `<label><span>${escapeHtml(label)}</span><div class="publish-copy-field"><input id="${id}" value="${escapeHtml(String(value))}" readonly><button type="button" data-copy-publish-field="${id}">Kopieren</button></div></label>`;
}
function bindSmartCopyActions(scope, listing, data, target, fields, tags) {
scope.querySelectorAll('[data-copy-publish-field]').forEach(button => {
button.addEventListener('click', () => copyPublishValue(document.getElementById(button.dataset.copyPublishField)?.value || '', button));
});
scope.querySelector('[data-copy-publish-description]')?.addEventListener('click', event => copyPublishValue(fields.description || '', event.currentTarget));
scope.querySelector('[data-copy-publish-html]')?.addEventListener('click', async event => {
try {
const html = await api('POST', '/api/html-wrap', { listing_id: listing.id, platform: listing.platform });
copyPublishValue(html.html || fields.description || '', event.currentTarget);
} catch (error) { toast(`HTML konnte nicht erstellt werden: ${error.message}`); }
});
scope.querySelector('[data-copy-publish-all]')?.addEventListener('click', event => copyPublishValue(`${fields.title || ''}\n\n${fields.description || ''}\n\n${tags}`, event.currentTarget));
scope.querySelector('[data-open-publish-platform]')?.addEventListener('click', () => { if (target.url) window.open(target.url, '_blank', 'noopener'); });
scope.querySelector('[data-mark-publish-live]')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true;
try {
await api('POST', '/api/publish/mark', { listing_id: listing.id, platform: listing.platform, status: 'published' });
toast('Artikel als veröffentlicht markiert');
closeSmartCopyModal();
await loadPublishList();
} catch (error) { toast(`Fehler: ${error.message}`); }
finally { button.disabled = false; }
});
scope.querySelector('[data-download-smart-photos]')?.addEventListener('click', () => downloadPublishPhotos(data.photos || []));
scope.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); });
}
function copyPublishValue(value, button) {
navigator.clipboard.writeText(String(value || '')).then(() => {
const old = button?.textContent;
if (button) button.textContent = 'Kopiert';
toast('In die Zwischenablage kopiert');
if (button) setTimeout(() => { button.textContent = old; }, 1200);
}).catch(() => toast('Kopieren nicht möglich'));
}
function publishReadinessItems(listing) {
return [
{ label: 'Titel', ok: Boolean(listing.title?.trim()) },
{ label: 'Beschreibung', ok: Boolean(listing.description?.trim()) },
{ label: 'Preis', ok: Number(listing.price) > 0 },
{ label: 'Bilder', ok: Boolean(listing.photos?.length) },
{ label: 'Kategorie', ok: Boolean(listing.category?.trim()) },
{ label: 'Zustand', ok: Boolean(listing.condition?.trim()) },
];
}
function publishReadinessScore(listing) {
const items = publishReadinessItems(listing);
return Math.round((items.filter(item => item.ok).length / items.length) * 100);
}
function renderDirectPublishWorkspace(listing) {
const workspace = document.getElementById('publish-workspace');
const target = publishPlatformTarget(listing.platform);
const connected = listing.platform === 'ebay-de'
? Boolean(publishState.integrations.ebay?.connected)
: listing.platform === 'etsy'
? Boolean(publishState.integrations.etsy?.connected)
: false;
const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || '');
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Direkt veröffentlichen', target.direct ? (connected ? 'Verbindung bereit' : 'Nicht verbunden') : 'Smart Copy erforderlich')}
<div class="publish-direct-grid">
<form id="publish-direct-form" class="publish-editor-column" autocomplete="off">
<section class="publish-editor-section"><div class="publish-field-head"><label for="publish-direct-title">Titel</label><span id="publish-direct-title-count">${String(listing.title || '').length}/80</span></div><input id="publish-direct-title" value="${escapeHtml(listing.title || '')}" maxlength="80"></section>
<section class="publish-editor-section"><div class="publish-field-head"><label for="publish-direct-description">Beschreibung</label><span id="publish-direct-description-count">${String(listing.description || '').length}/5000</span></div><textarea id="publish-direct-description" maxlength="5000">${escapeHtml(listing.description || '')}</textarea></section>
<section class="publish-editor-section"><div class="publish-field-head"><label for="publish-direct-tags">Tags</label><span>${Array.isArray(listing.tags) ? listing.tags.length : 0}</span></div><input id="publish-direct-tags" value="${escapeHtml(tags)}"></section>
<div class="publish-meta-form">
<label><span>Preis</span><input id="publish-direct-price" type="number" min="0" step="0.01" value="${listing.price || ''}"></label>
<label><span>Zustand</span><input id="publish-direct-condition" value="${escapeHtml(listing.condition || '')}"></label>
<label><span>Marke</span><input id="publish-direct-brand" value="${escapeHtml(listing.brand || '')}"></label>
<label><span>Modell / Größe</span><input id="publish-direct-size" value="${escapeHtml(listing.size || '')}"></label>
<label><span>Farbe</span><input id="publish-direct-color" value="${escapeHtml(listing.color || '')}"></label>
<label><span>Kategorie</span><input id="publish-direct-category" value="${escapeHtml(listing.category || '')}"></label>
</div>
</form>
<aside class="publish-readiness-column">
<section class="publish-readiness-card">
<div class="publish-readiness-head"><div><span class="publish-panel-kicker">Plattform</span><h4>${escapeHtml(target.name)}</h4></div><span class="integration-dot ${connected ? 'is-connected' : ''}"></span></div>
${target.direct ? `<p class="publish-integration-message">${connected ? 'Die API-Verbindung ist aktiv.' : 'Verbinde das Konto zuerst in den Einstellungen.'}</p>` : '<p class="publish-integration-message">Diese Plattform unterstützt derzeit Smart Copy über Vendoo.</p>'}
<ul>${publishReadinessItems(listing).map(item => `<li class="${item.ok ? 'is-ready' : 'is-missing'}"><span>${item.ok ? '✓' : '!'}</span>${escapeHtml(item.label)}</li>`).join('')}</ul>
<div class="publish-ready-message ${publishReadinessScore(listing) === 100 ? 'is-ready' : ''}"><strong>${publishReadinessScore(listing)}% vollständig</strong><small>Änderungen vor dem Publishing speichern.</small></div>
</section>
<section class="publish-photo-section"><div class="publish-field-head"><label>Fotos</label><span>${listing.photos?.length || 0}</span></div>${publishPhotoGrid(listing.photos || [])}</section>
</aside>
</div>
<footer class="publish-workspace-footer">
<div><button type="button" class="vd-button" data-publish-edit-listing>Detailansicht</button><button type="button" class="vd-button" data-publish-save-direct>Änderungen speichern</button></div>
<div>${listing.platform === 'ebay-de' ? '<button type="button" class="vd-button" data-add-current-ebay>Zur Queue</button>' : ''}<button type="button" class="vd-button primary" data-publish-direct>${target.direct ? 'Jetzt veröffentlichen' : 'Smart Copy öffnen'}</button></div>
</footer>`;
bindPublishWorkspaceNavigation(workspace);
const title = document.getElementById('publish-direct-title');
const description = document.getElementById('publish-direct-description');
title?.addEventListener('input', () => { document.getElementById('publish-direct-title-count').textContent = `${title.value.length}/80`; });
description?.addEventListener('input', () => { document.getElementById('publish-direct-description-count').textContent = `${description.value.length}/5000`; });
workspace.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); });
workspace.querySelector('[data-publish-save-direct]')?.addEventListener('click', () => saveDirectPublishDraft(listing));
workspace.querySelector('[data-add-current-ebay]')?.addEventListener('click', async () => {
publishState.selected.add(listing.id); updatePublishToolbar(); await batchEbayAdd();
});
workspace.querySelector('[data-publish-direct]')?.addEventListener('click', async event => {
if (!target.direct) { setPublishView('smart'); openSmartCopyModal(listing); return; }
if (!connected) { toast(`${target.name} ist nicht verbunden`); switchToTab('settings'); return; }
const saved = await saveDirectPublishDraft(listing, false);
if (!saved) return;
const button = event.currentTarget;
button.disabled = true; button.textContent = 'Wird eingereiht …';
try {
if (listing.platform === 'etsy') await api('POST', '/api/publish/etsy', { listing_id: listing.id });
else await api('POST', `/api/ebay/publish/${listing.id}`);
toast(`Auftrag für ${target.name} wurde sicher eingereiht`);
notify('Vendoo Publish', `${listing.title || listing.sku} wurde zur Publishing-Queue hinzugefügt.`);
await loadPublishList();
} catch (error) { toast(`Veröffentlichung fehlgeschlagen: ${error.message}`); }
finally { button.disabled = false; button.textContent = 'Jetzt veröffentlichen'; }
});
}
async function saveDirectPublishDraft(listing, showSuccess = true) {
const payload = {
title: document.getElementById('publish-direct-title')?.value.trim() || '',
description: document.getElementById('publish-direct-description')?.value.trim() || '',
tags: (document.getElementById('publish-direct-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean),
price: Number.parseFloat(document.getElementById('publish-direct-price')?.value) || null,
condition: document.getElementById('publish-direct-condition')?.value.trim() || null,
brand: document.getElementById('publish-direct-brand')?.value.trim() || null,
size: document.getElementById('publish-direct-size')?.value.trim() || null,
color: document.getElementById('publish-direct-color')?.value.trim() || null,
category: document.getElementById('publish-direct-category')?.value.trim() || null,
};
try {
const updated = await api('PUT', `/api/listings/${listing.id}`, payload);
const replace = items => items.map(item => item.id === updated.id ? updated : item);
publishState.listings = replace(publishState.listings);
publishState.allFiltered = replace(publishState.allFiltered);
if (showSuccess) toast('Artikel gespeichert');
return updated;
} catch (error) {
toast(`Speichern fehlgeschlagen: ${error.message}`);
return null;
}
}
function renderQueueWorkspaceLegacy() {
const workspace = document.getElementById('publish-workspace');
const queue = publishState.queue;
const counts = publishQueueCounts();
workspace.innerHTML = `<header class="publish-operation-header"><div><span class="publish-panel-kicker">eBay Automatisierung</span><h3>eBay Queue</h3><p>${counts.pending} ausstehend · ${counts.processing} in Bearbeitung · ${counts.failed} fehlgeschlagen</p></div><div><button class="vd-button" type="button" data-queue-clear>Queue leeren</button><button class="vd-button" type="button" data-queue-retry>Fehler wiederholen</button><button class="vd-button primary" type="button" data-queue-process>Alle verarbeiten</button></div></header>
<div class="publish-operation-list">${queue.length ? queue.map(item => publishQueueRow(item)).join('') : '<div class="publish-workspace-empty compact"><span>✓</span><h3>Queue ist leer</h3><p>Wähle links Artikel aus und füge sie zur eBay Queue hinzu.</p></div>'}</div>
<footer class="publish-operation-footer"><button class="vd-button" type="button" data-queue-add-selected ${publishState.selected.size ? '' : 'disabled'}>Ausgewählte Artikel hinzufügen</button><span>eBay-Verbindung: <strong>${publishState.integrations.ebay?.connected ? 'Verbunden' : 'Nicht verbunden'}</strong></span></footer>`;
workspace.querySelector('[data-queue-process]')?.addEventListener('click', processEbayQueue);
workspace.querySelector('[data-queue-retry]')?.addEventListener('click', retryEbayQueue);
workspace.querySelector('[data-queue-clear]')?.addEventListener('click', clearEbayQueueUI);
workspace.querySelector('[data-queue-add-selected]')?.addEventListener('click', batchEbayAdd);
workspace.querySelectorAll('[data-queue-remove]').forEach(button => button.addEventListener('click', async () => {
try { await api('DELETE', `/api/ebay/queue/${button.dataset.queueRemove}`); await loadPublishList(); }
catch (error) { toast(`Fehler: ${error.message}`); }
}));
}
function publishQueueRow(item) {
const labels = { pending: 'Ausstehend', uploading: 'In Bearbeitung', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' };
return `<article class="publish-operation-row">
<div class="publish-operation-product"><span class="publish-operation-mark">${escapeHtml(String(item.sku || 'VD').slice(-2))}</span><div><strong>${escapeHtml(item.title || 'Ohne Titel')}</strong><small>${escapeHtml(item.sku || '')}</small></div></div>
<div class="publish-operation-meta"><span>Status</span><b class="operation-status status-${escapeHtml(item.status)}">${escapeHtml(labels[item.status] || item.status)}</b></div>
<div class="publish-operation-meta"><span>Versuche</span><b>${Number(item.retries) || 0}</b></div>
<div class="publish-operation-error">${item.error ? `<span title="${escapeHtml(item.error)}">${escapeHtml(item.error)}</span>` : '<span>Keine Fehler</span>'}</div>
<div class="publish-operation-actions">${item.ebay_url ? `<a class="vd-button compact" href="${escapeHtml(item.ebay_url)}" target="_blank" rel="noopener">Ansehen</a>` : ''}<button class="icon-button danger" type="button" data-queue-remove="${item.id}" title="Entfernen">×</button></div>
</article>`;
}
function renderScheduledWorkspace() {
const workspace = document.getElementById('publish-workspace');
const items = publishState.scheduled;
workspace.innerHTML = `<header class="publish-operation-header"><div><span class="publish-panel-kicker">Zeitsteuerung</span><h3>Geplante Veröffentlichungen</h3><p>${items.length} Planung${items.length === 1 ? '' : 'en'} im System</p></div><button class="vd-button primary" type="button" data-schedule-selected ${publishState.selected.size || publishState.selectedId ? '' : 'disabled'}>Auswahl planen</button></header>
<div class="publish-schedule-list">${items.length ? items.map(item => publishScheduleRow(item)).join('') : '<div class="publish-workspace-empty compact"><span>◷</span><h3>Nichts geplant</h3><p>Plane ausgewählte Artikel für einen späteren Zeitpunkt.</p></div>'}</div>`;
workspace.querySelector('[data-schedule-selected]')?.addEventListener('click', openBatchSchedule);
workspace.querySelectorAll('[data-schedule-remove]').forEach(button => button.addEventListener('click', async () => {
try { await api('DELETE', `/api/schedule/${button.dataset.scheduleRemove}`); toast('Planung entfernt'); await loadPublishList(); }
catch (error) { toast(`Fehler: ${error.message}`); }
}));
}
function publishScheduleRow(item) {
const statusLabels = { scheduled: 'Geplant', publishing: 'In Bearbeitung', queued: 'An Queue übergeben', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' };
const date = new Date(String(item.scheduled_at || '').replace(' ', 'T') + (String(item.scheduled_at || '').includes('Z') ? '' : 'Z'));
const dateText = Number.isNaN(date.getTime()) ? item.scheduled_at : date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
return `<article class="publish-schedule-row">
<div class="publish-schedule-date"><strong>${escapeHtml(dateText || '—')}</strong><span>${escapeHtml(platformLabel(item.platform))}</span></div>
<div class="publish-operation-product"><span class="publish-operation-mark">${escapeHtml(String(item.sku || 'VD').slice(-2))}</span><div><strong>${escapeHtml(item.title || 'Ohne Titel')}</strong><small>${escapeHtml(item.sku || '')}</small></div></div>
<b class="operation-status status-${escapeHtml(item.status)}">${escapeHtml(statusLabels[item.status] || item.status)}</b>
${item.error ? `<span class="publish-schedule-error">${escapeHtml(item.error)}</span>` : '<span></span>'}
${item.status === 'scheduled' ? `<button class="icon-button danger" type="button" data-schedule-remove="${item.id}" title="Planung entfernen">×</button>` : '<span></span>'}
</article>`;
}
function publishQueueCountsLegacy() {
return publishState.queue.reduce((counts, item) => {
if (item.status === 'pending') counts.pending++;
else if (item.status === 'uploading') counts.processing++;
else if (item.status === 'published') counts.published++;
else if (item.status === 'failed') counts.failed++;
return counts;
}, { pending: 0, processing: 0, published: 0, failed: 0 });
}
function renderPublishStatusRailLegacy() {
const counts = publishQueueCounts();
const workflowPublished = publishState.workflow?.stages?.published?.count || 0;
const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length;
const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; };
setText('publish-status-pending', counts.pending);
setText('publish-status-processing', counts.processing);
setText('publish-status-published', Math.max(counts.published, workflowPublished));
setText('publish-status-failed', counts.failed);
setText('publish-status-scheduled', publishState.scheduled.length);
setText('publish-direct-count', directEligible);
setText('publish-queue-count', publishState.queue.length);
setText('publish-scheduled-count', publishState.scheduled.length);
setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler warten` : 'Keine Fehler');
setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen');
const activity = publishState.workflow?.activity || [];
const container = document.getElementById('publish-activity');
if (container) {
container.innerHTML = activity.length ? activity.slice(0, 6).map(item => `<div class="publish-activity-row"><span class="publish-activity-icon type-${escapeHtml(item.type || 'pending')}">${activityIcon(item.type)}</span><div><strong>${escapeHtml(item.text || '')}</strong><small>${relativeTime(item.at)}</small></div></div>`).join('') : '<div class="publish-rail-empty">Noch keine Publish-Aktivität</div>';
}
}
async function openSmartCopy(listingId) {
publishState.selectedId = Number(listingId);
setPublishView('smart');
renderPublishList();
openSmartCopyModal(selectedPublishListing());
}
async function batchSmartCopy() {
const id = [...publishState.selected][0] || publishState.selectedId;
if (!id) return;
openSmartCopy(id);
}
async function batchEtsyPublish() {
const ids = [...publishState.selected];
const eligible = publishState.listings.filter(listing => ids.includes(listing.id) && listing.platform === 'etsy');
if (!eligible.length) { toast('Keine ausgewählten Etsy-Artikel'); return; }
let succeeded = 0;
let failed = 0;
for (const listing of eligible) {
try { await api('POST', '/api/publish/etsy', { listing_id: listing.id }); succeeded++; }
catch { failed++; }
}
toast(`Etsy: ${succeeded} Auftrag${succeeded === 1 ? '' : 'e'} eingereiht${failed ? `, ${failed} fehlgeschlagen` : ''}`);
await loadPublishList();
}
async function batchEbayAddLegacy() {
const ids = [...publishState.selected];
if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId);
if (!ids.length) return;
try {
const response = await api('POST', '/api/ebay/queue', { listing_ids: ids });
toast(`${response.added} Listing${response.added === 1 ? '' : 's'} zur eBay Queue hinzugefügt`);
publishState.view = 'queue';
await loadPublishList();
setPublishView('queue');
} catch (error) { toast(`Fehler: ${error.message}`); }
}
async function openEbayQueueLegacy() { setPublishView('queue'); }
async function processEbayQueueLegacy() {
if (!publishState.integrations.ebay?.connected) { toast('eBay ist nicht verbunden'); switchToTab('settings'); return; }
const buttons = document.querySelectorAll('[data-queue-process], #publish-ebay-queue-process');
buttons.forEach(button => { button.disabled = true; });
try {
const response = await api('POST', '/api/ebay/queue/process');
const succeeded = response.results?.filter(item => item.status === 'published').length || 0;
const failed = response.results?.filter(item => item.status === 'failed').length || 0;
toast(`${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`);
notify('Vendoo — eBay Queue', `${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`);
await loadPublishList();
setPublishView('queue');
} catch (error) { toast(`Queue-Fehler: ${error.message}`); }
finally { buttons.forEach(button => { button.disabled = false; }); }
}
async function retryEbayQueueLegacy() {
try {
const response = await api('POST', '/api/ebay/queue/retry');
toast(`${response.retried} Einträge erneut eingeplant`);
await loadPublishList();
setPublishView('queue');
} catch (error) { toast(`Fehler: ${error.message}`); }
}
async function clearEbayQueueUI() {
if (!confirm('eBay Queue wirklich vollständig leeren?')) return;
try { await api('DELETE', '/api/ebay/queue'); toast('eBay Queue geleert'); await loadPublishList(); setPublishView('queue'); }
catch (error) { toast(`Fehler: ${error.message}`); }
}
let scheduleListingIds = [];
function openBatchSchedule() {
scheduleListingIds = [...publishState.selected];
if (!scheduleListingIds.length && publishState.selectedId) scheduleListingIds = [publishState.selectedId];
if (!scheduleListingIds.length) { toast('Bitte mindestens ein Artikel auswählen'); return; }
openPublishScheduleDialog(scheduleListingIds);
}
function openSingleSchedule(listingId) {
scheduleListingIds = [Number(listingId)];
openPublishScheduleDialog(scheduleListingIds);
}
function openPublishScheduleDialog(ids) {
const now = new Date(Date.now() + 30 * 60 * 1000);
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
const input = document.getElementById('schedule-datetime');
input.value = local;
input.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
document.getElementById('schedule-modal-title').textContent = ids.length === 1 ? 'Veröffentlichung planen' : `${ids.length} Veröffentlichungen planen`;
const selected = publishState.listings.filter(listing => ids.includes(listing.id));
document.getElementById('schedule-listing-summary').innerHTML = selected.slice(0, 4).map(listing => `<span>${escapeHtml(listing.sku || `VD-${listing.id}`)} · ${escapeHtml(listing.title || 'Ohne Titel')}</span>`).join('') + (selected.length > 4 ? `<b>+${selected.length - 4} weitere</b>` : '');
document.getElementById('schedule-modal').classList.remove('hidden');
document.getElementById('schedule-confirm').onclick = async () => {
const dateTime = input.value;
const platform = document.getElementById('schedule-platform').value;
if (!dateTime) { toast('Bitte Datum und Uhrzeit auswählen'); return; }
const utc = new Date(dateTime).toISOString().replace('T', ' ').slice(0, 19);
let succeeded = 0;
let failed = 0;
for (const id of ids) {
try { await api('POST', '/api/schedule', { listing_id: id, platform, scheduled_at: utc }); succeeded++; }
catch { failed++; }
}
toast(`${succeeded} Veröffentlichung${succeeded === 1 ? '' : 'en'} geplant${failed ? `, ${failed} fehlgeschlagen` : ''}`);
closePublishSchedule();
publishState.view = 'scheduled';
await loadPublishList();
setPublishView('scheduled');
};
}
function closePublishSchedule() {
document.getElementById('schedule-modal')?.classList.add('hidden');
}
async function openScheduleOverview() { setPublishView('scheduled'); }
async function batchDownloadPhotos() {
const ids = [...publishState.selected];
if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId);
if (!ids.length) return;
try {
const data = await api('POST', '/api/publish/batch-photos', { listing_ids: ids });
await downloadPublishPhotos(data.photos || []);
} catch (error) { toast(`Fehler: ${error.message}`); }
}
async function downloadPublishPhotos(photos) {
const unique = [...new Set((photos || []).filter(Boolean))];
if (!unique.length) { toast('Keine Fotos vorhanden'); return; }
if (unique.length === 1) {
const photo = unique[0];
const link = document.createElement('a');
link.href = photoUrl(photo);
link.download = String(photo).split('/').pop() || 'vendoo-foto.jpg';
document.body.appendChild(link);
link.click();
link.remove();
toast('Foto wird gespeichert');
return;
}
try {
const response = await fetch('/api/photos/archive', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ photos: unique }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: response.statusText }));
throw new Error(error.error || 'ZIP konnte nicht erstellt werden');
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `vendoo-fotos-${new Date().toISOString().slice(0, 10)}.zip`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1500);
toast(`${unique.length} Fotos wurden als ZIP gepackt`);
} catch (error) {
toast(`Foto-ZIP fehlgeschlagen: ${error.message}`);
}
}
// --- Central image gallery / media library ---
const MEDIA_SOURCE_LABELS = { flux: 'FLUX', edited: 'Bearbeitet', batch: 'Produktion', ai: 'AI-Bild', listing: 'Artikel' };
function formatMediaFileSize(bytes) {
const size = Number(bytes || 0);
if (!size) return 'Größe unbekannt';
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
}
function renderMediaSummary() {
const summary = state.mediaSummary || { total: 0, sources: {} };
const sources = summary.sources || {};
const values = { total: summary.total || 0, flux: sources.flux || 0, edited: sources.edited || 0, batch: sources.batch || 0, ai: sources.ai || 0, listing: sources.listing || 0 };
for (const [key, value] of Object.entries(values)) document.getElementById(`media-count-${key}`)?.replaceChildren(document.createTextNode(String(value)));
const current = document.getElementById('media-source')?.value || 'all';
document.querySelectorAll('[data-media-source]').forEach(button => button.classList.toggle('active', button.dataset.mediaSource === current));
}
function updateMediaToolbar() {
const count = state.mediaSelected.size;
document.getElementById('media-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`));
const download = document.getElementById('media-download-selected');
const remove = document.getElementById('media-delete-selected');
const factory = document.getElementById('media-send-to-factory');
if (download) download.disabled = count === 0;
if (remove) remove.disabled = count === 0;
if (factory) factory.disabled = count === 0;
const selectPage = document.getElementById('media-select-page');
if (selectPage) {
const selectedOnPage = state.mediaItems.filter(item => state.mediaSelected.has(item.image_path)).length;
selectPage.checked = state.mediaItems.length > 0 && selectedOnPage === state.mediaItems.length;
selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.mediaItems.length;
}
}
function renderMediaPagination() {
const pagination = state.mediaPagination || {};
const page = Math.max(1, Number(pagination.page || 1));
const pages = Math.max(1, Number(pagination.total_pages || 1));
const total = Math.max(0, Number(pagination.total || 0));
const pageSize = Math.max(1, Number(state.mediaPageSize || 24));
const first = total ? ((page - 1) * pageSize) + 1 : 0;
const last = total ? Math.min(total, first + state.mediaItems.length - 1) : 0;
document.querySelectorAll('[data-media-pagination]').forEach(paginationRoot => {
paginationRoot.querySelector('[data-media-page-range]')?.replaceChildren(document.createTextNode(total ? `${first}${last} von ${total} Bildern` : '0 Bilder'));
paginationRoot.querySelector('[data-media-page-info]')?.replaceChildren(document.createTextNode(`von ${pages}`));
const select = paginationRoot.querySelector('[data-media-page-jump]');
if (select) {
const signature = `${pages}:${page}`;
if (select.dataset.signature !== signature) {
select.innerHTML = Array.from({ length: pages }, (_, index) => `<option value="${index + 1}"${index + 1 === page ? ' selected' : ''}>${index + 1}</option>`).join('');
select.dataset.signature = signature;
} else select.value = String(page);
}
const prev = paginationRoot.querySelector('[data-media-page-action="prev"]');
const next = paginationRoot.querySelector('[data-media-page-action="next"]');
if (prev) prev.disabled = page <= 1;
if (next) next.disabled = page >= pages;
});
}
function mediaSourceBadges(item) {
return (item.sources || [item.source]).map(source => `<span class="media-source-chip is-${escapeHtml(source)}">${escapeHtml(MEDIA_SOURCE_LABELS[source] || source)}</span>`).join('');
}
function mediaActionIcon(name, size = 18) {
const map = { open: 'zoom-in', edit: 'pencil', use: 'plus', download: 'download', delete: 'trash-2' };
return window.VendooIcons?.svg(map[name] || name, { size, className: 'media-action-icon' }) || '';
}
function renderMediaLibrary() {
const container = document.getElementById('media-grid');
if (!container) return;
container.classList.toggle('is-list', state.mediaView === 'list');
if (!state.mediaItems.length) {
container.innerHTML = '<div class="media-empty"><strong>Keine Bilder gefunden</strong><span>Filter anpassen oder im FLUX Studio ein neues Bild erzeugen.</span><button class="vd-button primary" type="button" data-tab-target="flux-studio">FLUX Studio öffnen</button></div>';
container.querySelector('[data-tab-target]')?.addEventListener('click', () => switchToTab('flux-studio'));
updateMediaToolbar();
return;
}
container.innerHTML = state.mediaItems.map(item => {
const selected = state.mediaSelected.has(item.image_path);
const title = item.title || item.prompt || item.image_path.split('/').pop();
const dimensions = item.width && item.height ? `${item.width} × ${item.height}` : 'Abmessungen unbekannt';
const canFavorite = (item.sources || []).includes('flux');
return `<article class="media-card${selected ? ' is-selected' : ''}" data-media-path="${escapeHtml(item.image_path)}">
<div class="media-card-image">
<button type="button" class="media-card-open" data-media-preview="${escapeHtml(item.image_path)}" aria-label="${escapeHtml(title)} in großer Vorschau öffnen">
<img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="${escapeHtml(title)}" loading="lazy">
<span class="media-open-indicator" aria-hidden="true">${mediaActionIcon('open')}</span>
</button>
<label class="media-card-select" title="Bild auswählen"><input type="checkbox" data-media-select="${escapeHtml(item.image_path)}" ${selected ? 'checked' : ''} aria-label="Bild auswählen"><span aria-hidden="true">${window.VendooIcons?.svg('check', { size: 13 }) || ''}</span></label>
${canFavorite ? `<button type="button" class="media-card-favorite-toggle${item.favorite ? ' is-active' : ''}" data-media-favorite="${escapeHtml(item.image_path)}" aria-label="${item.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}" title="${item.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}">${window.VendooIcons?.svg(item.favorite ? 'heart-filled' : 'heart', { size: 16 }) || ''}</button>` : ''}
</div>
<div class="media-card-content">
<div class="media-card-sources">${mediaSourceBadges(item)}</div>
<strong title="${escapeHtml(title)}">${escapeHtml(title)}</strong>
<span>${escapeHtml(dimensions)} · ${escapeHtml(formatMediaFileSize(item.file_size))}</span>
<small title="${escapeHtml(item.image_path.split('/').pop())}">${escapeHtml(formatFluxDate(item.created_at))} · ${escapeHtml(item.image_path.split('/').pop())}</small>
</div>
<div class="media-card-actions" aria-label="Bildaktionen">
<button type="button" class="media-action" data-media-edit="${escapeHtml(item.image_path)}" aria-label="Bild bearbeiten" title="Bearbeiten">${mediaActionIcon('edit')}<span class="media-action-label">Bearbeiten</span></button>
<button type="button" class="media-action" data-media-use="${escapeHtml(item.image_path)}" aria-label="Bild verwenden" title="Verwenden">${mediaActionIcon('use')}<span class="media-action-label">Verwenden</span></button>
<a class="media-action" href="${escapeHtml(item.public_url || photoUrl(item.image_path))}" download aria-label="Bild herunterladen" title="Download">${mediaActionIcon('download')}<span class="media-action-label">Download</span></a>
<button type="button" class="media-action danger" data-media-delete="${escapeHtml(item.image_path)}" aria-label="Bild löschen" title="Löschen">${mediaActionIcon('delete')}<span class="media-action-label">Löschen</span></button>
</div>
</article>`;
}).join('');
container.querySelectorAll('[data-media-select]').forEach(input => input.addEventListener('change', () => {
if (input.checked) state.mediaSelected.add(input.dataset.mediaSelect); else state.mediaSelected.delete(input.dataset.mediaSelect);
input.closest('.media-card')?.classList.toggle('is-selected', input.checked);
updateMediaToolbar();
}));
container.querySelectorAll('[data-media-preview]').forEach(button => button.addEventListener('click', () => openMediaLightbox(findMediaItem(button.dataset.mediaPreview))));
container.querySelectorAll('[data-media-edit]').forEach(button => button.addEventListener('click', () => openMediaEditor(findMediaItem(button.dataset.mediaEdit))));
container.querySelectorAll('[data-media-use]').forEach(button => button.addEventListener('click', () => useMediaInGenerator(findMediaItem(button.dataset.mediaUse))));
container.querySelectorAll('[data-media-favorite]').forEach(button => button.addEventListener('click', () => toggleMediaFavorite(findMediaItem(button.dataset.mediaFavorite))));
container.querySelectorAll('[data-media-delete]').forEach(button => button.addEventListener('click', () => deleteMediaItems([button.dataset.mediaDelete])));
updateMediaToolbar();
window.VendooIcons?.render(container);
}
function findMediaItem(imagePath) {
return state.mediaItems.find(item => item.image_path === imagePath) || (state.mediaCurrent?.image_path === imagePath ? state.mediaCurrent : null);
}
async function loadMediaLibrary({ silent = false } = {}) {
const container = document.getElementById('media-grid');
if (!silent && container && !state.mediaItems.length) container.innerHTML = '<div class="empty-state">Bildergalerie wird geladen …</div>';
try {
const params = new URLSearchParams({
page: String(state.mediaPage), page_size: String(state.mediaPageSize),
source: document.getElementById('media-source')?.value || 'all',
sort: document.getElementById('media-sort')?.value || 'newest',
favorites: document.getElementById('media-favorites-only')?.checked ? '1' : '0',
});
const search = document.getElementById('media-search')?.value.trim();
if (search) params.set('q', search);
const result = await api('GET', `/api/media-library?${params}`);
state.mediaItems = result.items || [];
state.mediaPagination = result.pagination || { page: 1, total_pages: 1, total: state.mediaItems.length };
state.mediaSummary = result.summary || null;
state.mediaPage = Number(state.mediaPagination.page || 1);
state.mediaSelected = new Set([...state.mediaSelected].filter(path => state.mediaItems.some(item => item.image_path === path)));
renderMediaSummary(); renderMediaLibrary(); renderMediaPagination();
} catch (error) {
if (container) container.innerHTML = `<div class="empty-state">Bildergalerie konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
async function toggleMediaFavorite(item) {
if (!item) return;
try {
const result = await api('PATCH', '/api/media-library/favorite', { image_path: item.image_path, favorite: !item.favorite });
item.favorite = result.favorite;
if (state.mediaCurrent?.image_path === item.image_path) state.mediaCurrent.favorite = result.favorite;
renderMediaLibrary(); renderMediaLightbox();
} catch (error) { toast(error.message); }
}
async function deleteMediaItems(imagePaths) {
const paths = [...new Set((imagePaths || []).filter(Boolean))];
if (!paths.length) return;
if (!window.confirm(`${paths.length} Bild${paths.length === 1 ? '' : 'er'} endgültig aus der Medienbibliothek und vom Datenträger löschen? In Artikeln verwendete Bilder werden geschützt.`)) return;
try {
const result = await api('DELETE', '/api/media-library/items', { image_paths: paths });
state.mediaSelected.clear();
if (state.mediaCurrent && paths.includes(state.mediaCurrent.image_path)) closeMediaLightbox();
const protectedCount = result.protected?.length || 0;
toast(`${result.deleted || 0} Bild${result.deleted === 1 ? '' : 'er'} gelöscht${protectedCount ? ` · ${protectedCount} Listing-Bild${protectedCount === 1 ? '' : 'er'} geschützt` : ''}.`);
await loadMediaLibrary();
} catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); }
}
function useMediaInGenerator(item) {
if (!item?.image_path) return;
if (!state.photos.includes(item.image_path)) state.photos.push(item.image_path);
renderPhotoPreview();
switchToTab('generator');
toast('Bild wurde dem neuen Artikel hinzugefügt.');
}
function openMediaEditor(item) {
if (!item) return;
state.mediaCurrent = item;
if ((item.sources || []).includes('flux') && item.flux_id) {
state.fluxStudioCurrent = { ...item, id: item.flux_id, image_path: item.image_path, public_url: item.public_url };
openPhotoEditor(0, 'flux');
} else openPhotoEditor(0, 'media');
}
const mediaZoomState = { zoom: 1, fitZoom: 1, naturalWidth: 0, naturalHeight: 0, dragging: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 };
function updateMediaZoom(zoom, { center = false } = {}) {
const stage = document.getElementById('media-lightbox-stage');
const canvas = document.getElementById('media-lightbox-canvas');
const image = document.getElementById('media-lightbox-image');
if (!stage || !canvas || !image || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return;
const previousCenterX = stage.scrollLeft + stage.clientWidth / 2;
const previousCenterY = stage.scrollTop + stage.clientHeight / 2;
const previousWidth = Math.max(1, mediaZoomState.naturalWidth * mediaZoomState.zoom);
const previousHeight = Math.max(1, mediaZoomState.naturalHeight * mediaZoomState.zoom);
mediaZoomState.zoom = Math.min(4, Math.max(.1, Number(zoom) || 1));
const width = Math.max(1, Math.round(mediaZoomState.naturalWidth * mediaZoomState.zoom));
const height = Math.max(1, Math.round(mediaZoomState.naturalHeight * mediaZoomState.zoom));
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
image.style.width = '100%';
image.style.height = '100%';
const range = document.getElementById('media-zoom-range');
const output = document.getElementById('media-zoom-value');
if (range) range.value = String(Math.round(mediaZoomState.zoom * 100));
if (output) output.value = output.textContent = `${Math.round(mediaZoomState.zoom * 100)} %`;
if (center) {
const ratioX = previousCenterX / previousWidth;
const ratioY = previousCenterY / previousHeight;
requestAnimationFrame(() => {
stage.scrollLeft = Math.max(0, ratioX * width - stage.clientWidth / 2);
stage.scrollTop = Math.max(0, ratioY * height - stage.clientHeight / 2);
});
}
}
function fitMediaLightbox() {
const stage = document.getElementById('media-lightbox-stage');
if (!stage || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return;
const availableWidth = Math.max(120, stage.clientWidth - 28);
const availableHeight = Math.max(120, stage.clientHeight - 28);
mediaZoomState.fitZoom = Math.min(1, availableWidth / mediaZoomState.naturalWidth, availableHeight / mediaZoomState.naturalHeight);
updateMediaZoom(mediaZoomState.fitZoom);
requestAnimationFrame(() => { stage.scrollLeft = 0; stage.scrollTop = 0; });
}
function renderMediaLightbox() {
const item = state.mediaCurrent;
const dialog = document.getElementById('media-lightbox');
if (!dialog || dialog.classList.contains('hidden') || !item) return;
document.getElementById('media-lightbox-title').textContent = item.title || item.prompt || item.image_path.split('/').pop();
const image = document.getElementById('media-lightbox-image');
image.onload = () => {
mediaZoomState.naturalWidth = image.naturalWidth || Number(item.width) || 1;
mediaZoomState.naturalHeight = image.naturalHeight || Number(item.height) || 1;
fitMediaLightbox();
};
image.src = item.public_url || photoUrl(item.image_path);
document.getElementById('media-lightbox-download').href = item.public_url || photoUrl(item.image_path);
document.getElementById('media-lightbox-download').download = item.image_path.split('/').pop();
const favorite = document.getElementById('media-lightbox-favorite');
const canFavorite = (item.sources || []).includes('flux');
favorite?.classList.toggle('hidden', !canFavorite);
if (favorite) favorite.innerHTML = `${window.VendooIcons?.svg(item.favorite ? 'heart-filled' : 'heart', { size: 17 }) || ''} ${item.favorite ? 'Favorit entfernen' : 'Favorit'}`;
document.getElementById('media-lightbox-meta').innerHTML = `
<div class="media-card-sources">${mediaSourceBadges(item)}</div>
<dl><dt>Datei</dt><dd>${escapeHtml(item.image_path)}</dd><dt>Abmessungen</dt><dd>${item.width && item.height ? `${item.width} × ${item.height}` : 'Unbekannt'}</dd><dt>Dateigröße</dt><dd>${escapeHtml(formatMediaFileSize(item.file_size))}</dd><dt>Format</dt><dd>${escapeHtml(String(item.format || '—').toUpperCase())}</dd><dt>Erstellt</dt><dd>${escapeHtml(formatFluxDate(item.created_at))}</dd>${item.seed != null ? `<dt>Seed</dt><dd>${item.seed}</dd>` : ''}</dl>
${item.prompt ? `<div class="media-lightbox-prompt"><strong>Prompt</strong><p>${escapeHtml(item.prompt)}</p></div>` : ''}`;
window.VendooIcons?.render(dialog);
}
function openMediaLightbox(item) {
if (!item) return;
state.mediaCurrent = item;
const dialog = document.getElementById('media-lightbox');
dialog?.classList.remove('hidden');
dialog?.setAttribute('aria-hidden', 'false');
document.body.classList.add('modal-open');
mediaZoomState.zoom = 1;
renderMediaLightbox();
}
function closeMediaLightbox() {
const dialog = document.getElementById('media-lightbox');
dialog?.classList.add('hidden');
dialog?.setAttribute('aria-hidden', 'true');
document.body.classList.remove('modal-open');
const image = document.getElementById('media-lightbox-image');
if (image) image.onload = null;
}
function setupMediaLibrary() {
const root = document.getElementById('media-library');
if (!root) return;
const resetAndLoad = () => { state.mediaPage = 1; state.mediaSelected.clear(); loadMediaLibrary(); };
document.getElementById('media-refresh')?.addEventListener('click', () => loadMediaLibrary());
document.getElementById('media-source')?.addEventListener('change', resetAndLoad);
document.getElementById('media-sort')?.addEventListener('change', resetAndLoad);
document.getElementById('media-favorites-only')?.addEventListener('change', resetAndLoad);
document.getElementById('media-page-size')?.addEventListener('change', event => { state.mediaPageSize = Number(event.target.value) || 24; resetAndLoad(); });
document.getElementById('media-search')?.addEventListener('input', debounce(resetAndLoad, 280));
document.querySelectorAll('[data-media-source]').forEach(button => button.addEventListener('click', () => {
const select = document.getElementById('media-source'); if (select) select.value = button.dataset.mediaSource; resetAndLoad();
}));
document.getElementById('media-view-grid')?.addEventListener('click', () => { state.mediaView = 'grid'; document.getElementById('media-view-grid').classList.add('active'); document.getElementById('media-view-list').classList.remove('active'); renderMediaLibrary(); });
document.getElementById('media-view-list')?.addEventListener('click', () => { state.mediaView = 'list'; document.getElementById('media-view-list').classList.add('active'); document.getElementById('media-view-grid').classList.remove('active'); renderMediaLibrary(); });
document.getElementById('media-select-page')?.addEventListener('change', event => { for (const item of state.mediaItems) { if (event.target.checked) state.mediaSelected.add(item.image_path); else state.mediaSelected.delete(item.image_path); } renderMediaLibrary(); });
document.getElementById('media-download-selected')?.addEventListener('click', () => downloadPublishPhotos([...state.mediaSelected]));
document.getElementById('media-delete-selected')?.addEventListener('click', () => deleteMediaItems([...state.mediaSelected]));
root.querySelectorAll('[data-media-page-action]').forEach(button => button.addEventListener('click', () => {
const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1));
const target = button.dataset.mediaPageAction === 'prev' ? state.mediaPage - 1 : state.mediaPage + 1;
if (target >= 1 && target <= pages && target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); }
}));
root.querySelectorAll('[data-media-page-jump]').forEach(select => select.addEventListener('change', () => {
const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1));
const target = Math.min(pages, Math.max(1, Number(select.value || 1)));
if (target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); }
}));
document.querySelectorAll('[data-media-lightbox-close]').forEach(button => button.addEventListener('click', closeMediaLightbox));
document.getElementById('media-lightbox-edit')?.addEventListener('click', () => { closeMediaLightbox(); openMediaEditor(state.mediaCurrent); });
document.getElementById('media-lightbox-favorite')?.addEventListener('click', () => toggleMediaFavorite(state.mediaCurrent));
document.getElementById('media-lightbox-delete')?.addEventListener('click', () => deleteMediaItems(state.mediaCurrent ? [state.mediaCurrent.image_path] : []));
document.getElementById('media-zoom-in')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom + .15, { center: true }));
document.getElementById('media-zoom-out')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom - .15, { center: true }));
document.getElementById('media-zoom-fit')?.addEventListener('click', fitMediaLightbox);
document.getElementById('media-zoom-original')?.addEventListener('click', () => updateMediaZoom(1, { center: true }));
document.getElementById('media-zoom-range')?.addEventListener('input', event => updateMediaZoom(Number(event.target.value) / 100, { center: true }));
const zoomStage = document.getElementById('media-lightbox-stage');
zoomStage?.addEventListener('wheel', event => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
updateMediaZoom(mediaZoomState.zoom + (event.deltaY < 0 ? .1 : -.1), { center: true });
}, { passive: false });
zoomStage?.addEventListener('pointerdown', event => {
if (event.button !== 0 || mediaZoomState.zoom <= mediaZoomState.fitZoom + .01) return;
mediaZoomState.dragging = true; mediaZoomState.startX = event.clientX; mediaZoomState.startY = event.clientY;
mediaZoomState.scrollLeft = zoomStage.scrollLeft; mediaZoomState.scrollTop = zoomStage.scrollTop;
zoomStage.setPointerCapture(event.pointerId); zoomStage.classList.add('is-dragging');
});
zoomStage?.addEventListener('pointermove', event => {
if (!mediaZoomState.dragging) return;
zoomStage.scrollLeft = mediaZoomState.scrollLeft - (event.clientX - mediaZoomState.startX);
zoomStage.scrollTop = mediaZoomState.scrollTop - (event.clientY - mediaZoomState.startY);
});
const stopZoomDrag = event => { mediaZoomState.dragging = false; zoomStage?.classList.remove('is-dragging'); try { zoomStage?.releasePointerCapture(event.pointerId); } catch {} };
zoomStage?.addEventListener('pointerup', stopZoomDrag); zoomStage?.addEventListener('pointercancel', stopZoomDrag);
document.addEventListener('keydown', event => {
if (document.getElementById('media-lightbox')?.classList.contains('hidden')) return;
if (event.key === 'Escape') closeMediaLightbox();
if (event.key === '+' || event.key === '=') updateMediaZoom(mediaZoomState.zoom + .15, { center: true });
if (event.key === '-') updateMediaZoom(mediaZoomState.zoom - .15, { center: true });
if (event.key === '0') fitMediaLightbox();
});
}
// --- Browser extensions ---
const EXTENSION_BROWSERS = {
chrome: { name: 'Google Chrome', icon: 'chrome', managementUrl: 'chrome://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' },
edge: { name: 'Microsoft Edge', icon: 'edge', managementUrl: 'edge://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' },
firefox: { name: 'Mozilla Firefox', icon: 'firefox', managementUrl: 'about:debugging#/runtime/this-firefox', action: 'Temporäres Add-on laden', note: 'Für eine dauerhaft signierte Installation muss das Add-on über Mozilla signiert werden.' },
safari: { name: 'Apple Safari', icon: 'compass', managementUrl: 'Safari → Einstellungen → Erweiterungen', action: 'Safari Web Extension erstellen', note: 'Benötigt macOS und Xcode. Vendoo liefert WebExtension-Quelle und Konvertierungsskript mit.' },
};
function detectActiveBrowser() {
const ua = navigator.userAgent || '';
if (/Edg\//.test(ua)) return 'edge';
if (/Firefox\//.test(ua)) return 'firefox';
if (/Safari\//.test(ua) && !/Chrome\//.test(ua) && !/Chromium\//.test(ua)) return 'safari';
if (/Chrome\//.test(ua) || /Chromium\//.test(ua)) return 'chrome';
return 'chrome';
}
async function copyTextSafe(value) {
try { await navigator.clipboard.writeText(String(value || '')); return true; }
catch {
const area = document.createElement('textarea'); area.value = String(value || ''); area.style.position = 'fixed'; area.style.opacity = '0';
document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); area.remove(); return ok;
}
}
async function prepareBrowserExtension(browser, data = {}) {
const config = EXTENSION_BROWSERS[browser];
if (!config) return;
try {
const result = await api('POST', '/api/extensions/prepare', { browser });
await copyTextSafe(result.path || '');
const card = document.querySelector(`[data-extension-browser="${browser}"]`);
card?.querySelector('.extension-path code')?.replaceChildren(document.createTextNode(result.path || ''));
toast(result.opened
? `Installationsordner geöffnet und Pfad kopiert. Öffne jetzt ${config.managementUrl}.`
: `Ordnerpfad kopiert: ${result.path}`);
return result;
} catch (error) {
toast(`Extension konnte nicht vorbereitet werden: ${error.message}`);
throw error;
}
}
function renderExtensions(data = {}) {
const grid = document.getElementById('extensions-grid');
if (!grid) return;
const active = detectActiveBrowser();
const activeBrowser = EXTENSION_BROWSERS[active];
const activeBox = document.getElementById('extensions-active-browser');
if (activeBox) activeBox.innerHTML = `<span>Aktiver Browser</span><strong>${window.VendooIcons?.svg(activeBrowser.icon, { size: 19 }) || ''} ${escapeHtml(activeBrowser.name)}</strong>`;
const version = data.version || '1.9.0';
grid.innerHTML = Object.entries(EXTENSION_BROWSERS).map(([id, browser]) => {
const info = data.browsers?.[id] || {};
return `<article class="extension-card${id === active ? ' is-active' : ''}" data-extension-browser="${id}">
<header><div class="extension-browser-icon">${window.VendooIcons?.svg(browser.icon, { size: 27 }) || ''}</div><div><span>${id === active ? 'Aktiver Browser' : 'Verfügbar'}</span><h3>${escapeHtml(browser.name)}</h3></div><b>v${escapeHtml(version)}</b></header>
<p>${escapeHtml(browser.note)}</p>
<div class="extension-path"><span>Installationsordner</span><code>${escapeHtml(info.path || 'Wird vom Server ermittelt …')}</code></div>
<ol><li>Installation vorbereiten</li><li><code>${escapeHtml(browser.managementUrl)}</code> öffnen</li><li>${escapeHtml(browser.action)}</li></ol>
<div class="extension-card-actions">
<button type="button" class="vd-button primary" data-extension-prepare="${id}">${window.VendooIcons?.svg('folder-open', { size: 17 }) || ''} Installation vorbereiten</button>
<button type="button" class="icon-button" data-extension-copy-path="${id}" title="Ordnerpfad kopieren" aria-label="Ordnerpfad kopieren">${window.VendooIcons?.svg('copy', { size: 17 }) || ''}</button>
<a class="icon-button" href="/api/extensions/download/${id}" title="Extension-Paket herunterladen" aria-label="Extension-Paket herunterladen">${window.VendooIcons?.svg('download', { size: 17 }) || ''}</a>
</div>
${id === 'safari' ? '<small>Safari lässt Erweiterungen nur über eine signierte macOS-App zu. Das Paket enthält dafür ein Xcode-Konvertierungsskript.</small>' : '<small>Die finale Bestätigung erfolgt im Browser; eine stille automatische Installation ist aus Sicherheitsgründen gesperrt.</small>'}
</article>`;
}).join('');
grid.querySelectorAll('[data-extension-prepare]').forEach(button => button.addEventListener('click', async () => {
const browser = button.dataset.extensionPrepare;
const old = button.innerHTML; button.disabled = true; button.textContent = 'Wird vorbereitet …';
try { await prepareBrowserExtension(browser, data); } catch {}
finally { button.disabled = false; button.innerHTML = old; }
}));
grid.querySelectorAll('[data-extension-copy-path]').forEach(button => button.addEventListener('click', async () => {
const path = data.browsers?.[button.dataset.extensionCopyPath]?.path || button.closest('.extension-card')?.querySelector('.extension-path code')?.textContent || '';
await copyTextSafe(path); toast('Ordnerpfad kopiert');
}));
window.VendooIcons?.render(document.getElementById('extensions'));
}
async function loadExtensions() {
const grid = document.getElementById('extensions-grid');
if (grid && !grid.children.length) grid.innerHTML = '<div class="empty-state">Extensions werden geprüft …</div>';
try { renderExtensions(await api('GET', '/api/extensions/info')); }
catch (error) { if (grid) grid.innerHTML = `<div class="empty-state">Extension-Informationen konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`; }
}
function setupExtensions() {
window.VendooIcons?.render(document.getElementById('extensions') || document);
document.getElementById('extensions-prepare-active')?.addEventListener('click', async event => {
const button = event.currentTarget;
const browser = detectActiveBrowser();
const old = button.innerHTML;
button.disabled = true;
button.textContent = 'Wird vorbereitet …';
try { await prepareBrowserExtension(browser); } catch {}
finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); }
});
}
// --- Settings ---
async function detectPublicBaseUrl({ apply = true } = {}) {
const input = document.getElementById('setting-public-base-url');
const status = document.getElementById('public-base-url-status');
const button = document.getElementById('detect-public-base-url-btn');
if (button) button.disabled = true;
if (status) status.textContent = 'LAN-Adresse wird erkannt …';
try {
const info = await api('GET', '/api/network-info');
const preferred = info.public_base_url || info.preferred_url || '';
if (apply && input && preferred) input.value = preferred;
const adapter = info.addresses?.[0];
if (status) {
status.textContent = adapter
? `Erkannt: ${preferred} · Adapter: ${adapter.name}`
: `Keine LAN-IP erkannt. Aktuelle Adresse: ${preferred || 'localhost'}`;
}
return info;
} catch (err) {
if (status) status.textContent = `LAN-Adresse konnte nicht erkannt werden: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
function renderLocalFluxInstallState(install = {}) {
const wrap = document.getElementById('local-flux-progress-wrap');
const label = document.getElementById('local-flux-progress-label');
const value = document.getElementById('local-flux-progress-value');
const bar = document.getElementById('local-flux-progress-bar');
const phase = document.getElementById('local-flux-phase-label');
const updated = document.getElementById('local-flux-updated-at');
const checksWrap = document.getElementById('local-flux-checks');
const errorWrap = document.getElementById('local-flux-error');
const logTail = document.getElementById('local-flux-log-tail');
const sevenZipTail = document.getElementById('local-flux-7zip-tail');
const installButton = document.getElementById('install-local-image-provider-btn');
const installerState = document.getElementById('local-flux-installer-state');
const elapsed = document.getElementById('local-flux-elapsed');
const extractFiles = document.getElementById('local-flux-extract-files');
const extractSize = document.getElementById('local-flux-extract-size');
const heartbeat = document.getElementById('local-flux-heartbeat');
const progress = Math.max(0, Math.min(100, Number(install.progress ?? install.readiness ?? 0)));
if (wrap) wrap.classList.remove('hidden');
if (label) label.textContent = install.message || install.phase_label || 'Lokale FLUX-Installation';
if (value) value.textContent = `${progress}%`;
if (bar) {
bar.style.width = `${progress}%`;
bar.classList.toggle('is-stalled', install.status === 'stalled');
bar.classList.toggle('is-failed', install.status === 'failed');
}
if (phase) {
const phaseText = install.installation_complete ? 'Vollständig installiert' : (install.phase_label || install.phase || 'Nicht installiert');
phase.textContent = `${phaseText} · ${Number(install.readiness || 0)}% geprüft`;
}
if (updated) {
const date = install.updated_at ? new Date(install.updated_at) : null;
updated.textContent = date && !Number.isNaN(date.getTime()) ? `Stand: ${date.toLocaleString('de-DE')}` : 'Stand: ';
}
const metrics = install.extract_metrics || {};
if (installerState) {
const labels = { running: 'Läuft', completed: 'Fertig', failed: 'Fehler', stalled: 'Hängt', 'not-started': 'Nicht gestartet', unknown: 'Unbekannt' };
installerState.textContent = labels[install.status] || install.status || 'Bereit';
installerState.className = `local-flux-state-value is-${escapeHtml(install.status || 'idle')}`;
}
if (elapsed) elapsed.textContent = `${Number(install.elapsed_seconds || metrics.elapsed_seconds || 0)} s`;
if (extractFiles) extractFiles.textContent = `${Number(metrics.file_count || 0)} Dateien`;
if (extractSize) extractSize.textContent = `${Number(metrics.size_mb || 0)} MB`;
if (heartbeat) {
heartbeat.textContent = install.heartbeat_age_seconds === null || install.heartbeat_age_seconds === undefined
? ''
: `${Number(install.heartbeat_age_seconds)} s`;
}
const checkLabels = {
seven_zip: '7-Zip',
archive: 'ComfyUI-Paket',
embedded_python: 'Python Runtime',
comfyui: 'ComfyUI',
model: 'FLUX-Modell',
workflow: 'Workflow',
start_script: 'Startskript',
stop_script: 'Stoppskript',
};
if (checksWrap) {
const checks = install.checks || {};
checksWrap.innerHTML = Object.entries(checkLabels).map(([key, title]) => {
const check = checks[key] || {};
let detail = check.ok ? 'Vorhanden' : 'Fehlt';
if (check.size_gb) detail += ` · ${check.size_gb} GB`;
else if (check.size_mb) detail += ` · ${check.size_mb} MB`;
return `<div class="local-flux-check ${check.ok ? 'is-ok' : 'is-missing'}"><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail)}</span></div>`;
}).join('');
}
if (errorWrap) {
const failed = install.status === 'failed' || install.status === 'stalled';
errorWrap.classList.toggle('hidden', !failed);
errorWrap.textContent = failed
? `${install.message || 'Installation fehlgeschlagen.'}${install.error_position ? `
${install.error_position}` : ''}
Die vorhandenen Downloads bleiben erhalten. „Installation fortsetzen“ startet nur den fehlenden Schritt erneut.`
: '';
}
if (logTail) {
const lines = Array.isArray(install.log_tail) ? install.log_tail : [];
logTail.textContent = lines.length ? lines.join('\n') : 'Noch kein Installationsprotokoll vorhanden.';
}
if (sevenZipTail) {
const lines = [
...(Array.isArray(install.seven_zip_error_tail) ? install.seven_zip_error_tail : []),
...(Array.isArray(install.seven_zip_output_tail) ? install.seven_zip_output_tail : []),
];
sevenZipTail.textContent = lines.length ? lines.slice(-40).join('\n') : 'Noch keine 7-Zip-Ausgabe vorhanden.';
}
const healthInstall = document.getElementById('local-flux-health-install');
const healthModel = document.getElementById('local-flux-health-model');
if (healthInstall) healthInstall.textContent = install.installation_complete ? 'Vollständig' : install.checks?.comfyui?.ok ? 'Unvollständig' : 'Nicht installiert';
if (healthModel) healthModel.textContent = install.checks?.model?.ok ? 'Vorhanden' : 'Fehlt';
if (installButton) {
if (install.status === 'failed' || install.status === 'stalled') installButton.textContent = 'Installation fortsetzen';
else if (install.checks?.comfyui?.ok && !install.checks?.model?.ok) installButton.textContent = 'FLUX-Modell nachinstallieren';
else if (install.can_resume) installButton.textContent = 'Installation fortsetzen';
else if (install.status === 'completed') installButton.textContent = 'Installation prüfen / ergänzen';
else installButton.textContent = 'Lokales FLUX installieren';
}
}
async function getDetailedLocalFluxInstallStatus() {
try {
const install = await api('GET', '/api/local-image/install-status');
renderLocalFluxInstallState(install);
return install;
} catch (err) {
const status = document.getElementById('local-image-provider-status');
if (status) status.textContent = `Installationsstatus konnte nicht gelesen werden: ${err.message}`;
return null;
}
}
let localFluxStatusTimer = null;
let localFluxStatusWatchActive = false;
async function watchLocalFluxInstallStatus() {
if (localFluxStatusWatchActive) return;
localFluxStatusWatchActive = true;
const run = async () => {
try {
const install = await getDetailedLocalFluxInstallStatus();
const delay = install && ['running', 'stalled'].includes(install.status) ? 2000 : 10000;
localFluxStatusTimer = setTimeout(run, delay);
} catch {
localFluxStatusTimer = setTimeout(run, 10000);
}
};
run();
}
async function pollLocalFluxInstall(startedAt = Date.now()) {
const status = document.getElementById('local-image-provider-status');
const pollStarted = Date.now();
while (Date.now() - pollStarted < 60 * 60 * 1000) {
const install = await getDetailedLocalFluxInstallStatus();
if (!install) return null;
if (status) status.textContent = install.message || 'Lokale FLUX-Installation läuft ...';
const updatedAt = Date.parse(install.updated_at || '') || 0;
const isFreshTerminal = ['completed', 'failed'].includes(install.status) && (updatedAt >= startedAt - 2000 || Date.now() - pollStarted > 8000);
if (isFreshTerminal) {
if (install.status === 'completed') await testLocalImageProvider({ skipInstallFetch: true });
else if (status) status.textContent = `Installation fehlgeschlagen: ${install.message || 'Unbekannter Fehler'}`;
return install;
}
await new Promise(resolve => setTimeout(resolve, 2000));
}
if (status) status.textContent = 'Die Installation läuft möglicherweise weiter. Nutze „Status prüfen“, um den aktuellen Stand zu laden.';
return null;
}
function updateLocalFluxCommandCenter(result = {}, install = {}) {
const stateName = result.engine_state || (!result.enabled ? 'disabled' : result.reachable ? 'running' : install.installation_complete ? 'stopped' : 'not-installed');
const labels = {
running: ['Aktiv und einsatzbereit', 'ComfyUI läuft. Vendoo kann lokale AI-Bilder erzeugen.'],
stopped: ['Installiert, aber gestoppt', 'Ein Klick auf „Server starten“ aktiviert die lokale Bild-Engine.'],
disabled: ['Deaktiviert', 'Vendoo nutzt lokales FLUX aktuell nicht. Installation und Dateien bleiben erhalten.'],
'not-installed': ['Nicht installiert', 'Installiere ComfyUI und FLUX direkt hier Vendoo führt dich automatisch durch den Vorgang.'],
};
const [title, detail] = labels[stateName] || labels['not-installed'];
const orb = document.getElementById('local-flux-orb');
const titleEl = document.getElementById('local-flux-engine-title');
const detailEl = document.getElementById('local-flux-engine-detail');
const badge = document.getElementById('local-flux-engine-badge');
const enableLabel = document.getElementById('local-flux-enable-label');
const serverHealth = document.getElementById('local-flux-health-server');
const referenceHealth = document.getElementById('local-flux-health-reference');
const heroText = document.getElementById('settings-flux-status-text');
const heroChip = document.getElementById('settings-flux-status-chip');
if (titleEl) titleEl.textContent = title;
if (detailEl) detailEl.textContent = detail;
if (badge) { badge.textContent = title; badge.className = `flux-state-badge is-${stateName}`; }
if (orb) orb.className = `flux-orb is-${stateName}`;
if (enableLabel) enableLabel.textContent = result.enabled ? 'Aktiviert' : 'Deaktiviert';
if (serverHealth) serverHealth.textContent = result.reachable ? 'Online' : install.installation_complete ? 'Gestoppt' : 'Nicht verfügbar';
if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : 'Aktiv';
if (heroText) heroText.textContent = title;
if (heroChip) heroChip.className = `settings-status-chip is-${stateName}`;
const startBtn = document.getElementById('start-local-image-provider-btn');
const stopBtn = document.getElementById('stop-local-image-provider-btn');
const uninstallBtn = document.getElementById('uninstall-local-image-provider-btn');
if (startBtn) startBtn.disabled = !install.installation_complete || result.reachable || !result.enabled;
if (stopBtn) stopBtn.disabled = !result.reachable;
if (uninstallBtn) uninstallBtn.disabled = !install.checks?.comfyui?.ok && !install.checks?.model?.ok;
}
async function setLocalFluxEnabled(enabled) {
const toggle = document.getElementById('setting-ai-model-photo-local-enabled');
const status = document.getElementById('local-image-provider-status');
if (toggle) toggle.disabled = true;
try {
await api('PUT', '/api/settings', { ai_model_photo_local_enabled: enabled ? '1' : '0' });
if (status) status.textContent = enabled ? 'Lokales FLUX wurde aktiviert.' : 'Lokales FLUX wurde deaktiviert.';
if (!enabled) {
try { await api('POST', '/api/local-image/stop', {}); } catch {}
} else {
const current = await api('GET', '/api/local-image/status');
if (current.install?.installation_complete && !current.reachable) {
try { await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' }); } catch {}
await new Promise(resolve => setTimeout(resolve, 3500));
}
}
await testLocalImageProvider();
} catch (err) {
if (toggle) toggle.checked = !enabled;
if (status) status.textContent = `Aktivierung konnte nicht geändert werden: ${err.message}`;
} finally {
if (toggle) toggle.disabled = false;
}
}
async function uninstallLocalImageProvider() {
const button = document.getElementById('uninstall-local-image-provider-btn');
const status = document.getElementById('local-image-provider-status');
const confirmed = window.confirm('Lokales FLUX wirklich deinstallieren? ComfyUI und das große FLUX-Modell werden entfernt. Deine Vendoo-Daten und heruntergeladenen Installationsarchive bleiben erhalten.');
if (!confirmed) return;
if (button) button.disabled = true;
try {
const result = await api('POST', '/api/local-image/uninstall', { remove_downloads: false });
if (status) status.textContent = result.message || 'Deinstallation wurde gestartet.';
await new Promise(resolve => setTimeout(resolve, 2500));
const started = Date.now();
while (Date.now() - started < 120000) {
const install = await getDetailedLocalFluxInstallStatus();
if (install && !install.checks?.comfyui?.ok && !install.checks?.model?.ok) break;
await new Promise(resolve => setTimeout(resolve, 2000));
}
const toggle = document.getElementById('setting-ai-model-photo-local-enabled');
if (toggle) toggle.checked = false;
await testLocalImageProvider();
toast('Lokales FLUX wurde deinstalliert.');
} catch (err) {
if (status) status.textContent = `Deinstallation fehlgeschlagen: ${err.message}`;
} finally {
if (button) button.disabled = false;
}
}
function setupSettingsNavigation() {
const buttons = [...document.querySelectorAll('[data-settings-panel]')];
const panels = [...document.querySelectorAll('[data-settings-panel-content]')];
const activate = (name) => {
buttons.forEach(button => button.classList.toggle('active', button.dataset.settingsPanel === name));
panels.forEach(panel => panel.classList.toggle('active', panel.dataset.settingsPanelContent === name));
try { localStorage.setItem('vendoo-settings-panel', name); } catch {}
};
buttons.forEach(button => button.addEventListener('click', () => activate(button.dataset.settingsPanel)));
let initial = 'general';
try { initial = localStorage.getItem('vendoo-settings-panel') || initial; } catch {}
if (!panels.some(panel => panel.dataset.settingsPanelContent === initial)) initial = 'general';
activate(initial);
}
function updateReferenceStrengthUi() {
const range = document.getElementById('setting-ai-model-photo-reference-strength');
const output = document.getElementById('setting-ai-model-photo-reference-strength-value');
if (range && output) output.textContent = `${range.value}%`;
const referenceHealth = document.getElementById('local-flux-health-reference');
if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : `${range?.value || 70}%`;
}
async function testLocalImageProvider(options = {}) {
const status = document.getElementById('local-image-provider-status');
const button = document.getElementById('test-local-image-provider-btn');
if (button) button.disabled = true;
if (status) status.textContent = 'Lokaler FLUX/ComfyUI-Status wird geprüft ...';
try {
const result = await api('GET', '/api/local-image/status');
const install = options.skipInstallFetch ? (result.install || {}) : (await getDetailedLocalFluxInstallStatus() || result.install || {});
if (options.skipInstallFetch) renderLocalFluxInstallState(install);
updateLocalFluxCommandCenter(result, install);
if (status) {
if (install.status === 'failed') {
status.textContent = `Installation fehlgeschlagen · ${install.message || 'Details im Installationsstatus'}`;
} else if (result.reachable) {
status.textContent = `Aktiv · ${result.url} erreichbar · Modell und Workflow bereit${result.reference_image_supported ? ' · Produktreferenz unterstützt' : ''}`;
} else {
status.textContent = install.installation_complete ? 'Installiert, aber der Bildserver ist gestoppt.' : `Noch nicht vollständig installiert · ${install.phase_label || 'Installation erforderlich'}`;
}
}
return { ...result, install };
} catch (err) {
if (status) status.textContent = `Lokaler FLUX-Test fehlgeschlagen: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
async function installLocalImageProvider() {
const status = document.getElementById('local-image-provider-status');
const button = document.getElementById('install-local-image-provider-btn');
const installModel = document.getElementById('local-flux-install-model')?.checked !== false;
const enableAutostart = document.getElementById('local-flux-enable-autostart')?.checked !== false;
const startNow = document.getElementById('local-flux-start-now')?.checked !== false;
const warning = installModel
? 'ComfyUI und FLUX.1-schnell FP8 werden heruntergeladen. Das benötigt viel Speicherplatz und kann lange dauern. Installation starten?'
: 'ComfyUI wird installiert, das FLUX-Modell jedoch nicht. Installation starten?';
if (!window.confirm(warning)) return null;
if (button) button.disabled = true;
if (status) status.textContent = 'Lokale FLUX-Installation wird gestartet ...';
try {
const startedAt = Date.now();
const result = await api('POST', '/api/local-image/install', {
install_model: installModel,
enable_autostart: enableAutostart,
start_now: startNow,
gpu: 'auto',
});
if (status) status.textContent = result.message || 'Lokale FLUX-Installation wurde gestartet.';
await new Promise(resolve => setTimeout(resolve, 900));
await pollLocalFluxInstall(startedAt);
return result;
} catch (err) {
if (status) status.textContent = `Lokale FLUX-Installation fehlgeschlagen: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
async function updateLocalImageProvider() {
const status = document.getElementById('local-image-provider-status');
const button = document.getElementById('update-local-image-provider-btn');
if (!window.confirm('Lokales ComfyUI jetzt aktualisieren? Bestehende Modelle bleiben erhalten.')) return null;
if (button) button.disabled = true;
try {
const startedAt = Date.now();
const result = await api('POST', '/api/local-image/update', {});
if (status) status.textContent = result.message || 'Lokales FLUX-Update wurde gestartet.';
await new Promise(resolve => setTimeout(resolve, 900));
await pollLocalFluxInstall(startedAt);
return result;
} catch (err) {
if (status) status.textContent = `Update fehlgeschlagen: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
async function startLocalImageProvider() {
const status = document.getElementById('local-image-provider-status');
const button = document.getElementById('start-local-image-provider-btn');
if (button) button.disabled = true;
try {
const result = await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' });
if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird gestartet.';
await waitForFluxEngineReady({ statusElement: status });
return testLocalImageProvider();
} catch (err) {
if (status) status.textContent = `Start fehlgeschlagen: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
async function stopLocalImageProvider() {
const status = document.getElementById('local-image-provider-status');
const button = document.getElementById('stop-local-image-provider-btn');
if (button) button.disabled = true;
try {
const result = await api('POST', '/api/local-image/stop', {});
if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird beendet.';
await new Promise(resolve => setTimeout(resolve, 1800));
return testLocalImageProvider();
} catch (err) {
if (status) status.textContent = `Stoppen fehlgeschlagen: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
async function regenerateLocalImageToken() {
const field = document.getElementById('setting-ai-model-photo-local-token');
const status = document.getElementById('local-image-provider-status');
const button = document.getElementById('regenerate-local-image-token-btn');
if (button) button.disabled = true;
try {
const result = await api('POST', '/api/local-image/regenerate-token', {});
if (field) field.value = result.token || '';
if (status) status.textContent = 'Neuer lokaler Zugriffstoken erzeugt. Bitte Einstellungen speichern.';
return result;
} catch (err) {
if (status) status.textContent = `Token konnte nicht erneuert werden: ${err.message}`;
return null;
} finally {
if (button) button.disabled = false;
}
}
function updateAiPhotoProviderSettingsUi() {
const provider = document.getElementById('setting-ai-model-photo-provider')?.value || 'auto';
const openrouterSelect = document.getElementById('setting-ai-model-photo-openrouter-model');
const refreshBtn = document.getElementById('refresh-openrouter-models-btn');
const freeOnly = document.getElementById('setting-ai-model-photo-free-only');
const openAiField = document.getElementById('setting-ai-model-photo-openai-model');
const localUrlField = document.getElementById('setting-ai-model-photo-local-url');
const localTokenField = document.getElementById('setting-ai-model-photo-local-token');
const localWorkflowField = document.getElementById('setting-ai-model-photo-local-workflow-path');
const localInstallField = document.getElementById('setting-ai-model-photo-local-install-path');
const localEnabled = document.getElementById('setting-ai-model-photo-local-enabled');
const localRequireToken = document.getElementById('setting-ai-model-photo-local-require-token');
const testBtn = document.getElementById('test-local-image-provider-btn');
const installBtn = document.getElementById('install-local-image-provider-btn');
const updateBtn = document.getElementById('update-local-image-provider-btn');
const startBtn = document.getElementById('start-local-image-provider-btn');
const stopBtn = document.getElementById('stop-local-image-provider-btn');
const regenBtn = document.getElementById('regenerate-local-image-token-btn');
const useLocal = provider === 'auto' || provider === 'local';
const useOpenRouter = provider !== 'openai' && provider !== 'local';
const useOpenAi = provider !== 'openrouter' && provider !== 'local';
if (openrouterSelect) openrouterSelect.disabled = !useOpenRouter;
if (refreshBtn) refreshBtn.disabled = !useOpenRouter;
if (freeOnly) freeOnly.disabled = !useOpenRouter;
if (openAiField) openAiField.disabled = !useOpenAi;
if (localUrlField) localUrlField.disabled = !useLocal;
if (localTokenField) localTokenField.disabled = !useLocal;
if (localWorkflowField) localWorkflowField.disabled = !useLocal;
if (localInstallField) localInstallField.disabled = !useLocal;
if (localEnabled) localEnabled.disabled = false;
if (localRequireToken) localRequireToken.disabled = !useLocal;
if (testBtn) testBtn.disabled = false;
if (installBtn) installBtn.disabled = false;
if (updateBtn) updateBtn.disabled = false;
// Start/Stop werden ausschließlich vom realen Runtime-Status gesteuert.
if (regenBtn) regenBtn.disabled = false;
}
function renderOpenRouterModelPreview(models = [], selectedId = '', freeOnly = true) {
const wrap = document.getElementById('openrouter-models-preview');
const status = document.getElementById('openrouter-models-status');
const select = document.getElementById('setting-ai-model-photo-openrouter-model');
if (!wrap || !status || !select) return;
const normalized = Array.isArray(models) ? models : [];
const options = ['<option value="">Automatisch bestes Modell wählen</option>'];
normalized.forEach(model => {
const label = `${model.name || model.id}${model.is_free ? ' · Free' : ''}`;
options.push(`<option value="${escapeHtml(model.id || '')}">${escapeHtml(label)}</option>`);
});
select.innerHTML = options.join('');
if (selectedId) select.value = selectedId;
if (!select.value) select.value = '';
if (!normalized.length) {
wrap.classList.add('hidden');
wrap.innerHTML = '';
status.textContent = freeOnly
? 'Keine kostenlosen OpenRouter-Bildmodelle gefunden. In Auto nutzt Vendoo dann OpenAI als Fallback.'
: 'Keine OpenRouter-Bildmodelle gefunden.';
return;
}
const top = normalized.slice(0, 4);
wrap.classList.remove('hidden');
wrap.innerHTML = top.map(model => `
<div class="openrouter-model-card">
<div class="openrouter-model-top">
<div>
<strong>${escapeHtml(model.name || model.id || 'OpenRouter Modell')}</strong>
<span>${escapeHtml(model.id || '')}</span>
</div>
<div class="openrouter-model-badges">
<span class="openrouter-badge ${model.is_free ? 'is-free' : ''}">${model.is_free ? 'Free' : 'Paid'}</span>
<span class="openrouter-badge">Image</span>
</div>
</div>
<p class="openrouter-model-description">${escapeHtml((model.description || 'Kein Beschreibungstext verfügbar.').slice(0, 220))}</p>
</div>
`).join('');
status.textContent = `${normalized.length} OpenRouter-Bildmodell(e) verfügbar${freeOnly ? ' (Free gefiltert)' : ''}.`;
}
async function loadOpenRouterImageModels(selectedId = '') {
const status = document.getElementById('openrouter-models-status');
const freeOnly = document.getElementById('setting-ai-model-photo-free-only')?.checked !== false;
if (status) status.textContent = 'OpenRouter-Bildmodelle werden geladen ...';
try {
const result = await api('GET', `/api/ai-model-photos/providers/openrouter/models?free_only=${freeOnly ? '1' : '0'}`);
state.openRouterImageModels = result.models || [];
renderOpenRouterModelPreview(state.openRouterImageModels, selectedId, !!result.free_only);
if (freeOnly && state.openRouterImageModels.length === 0) {
try {
const diagnostics = await api('GET', '/api/ai-model-photos/providers/openrouter/diagnostics');
if (status) {
status.textContent = diagnostics.free_image_model_count > 0
? `${diagnostics.free_image_model_count} Free-Bildmodell(e) erkannt, aber aktuell nicht nutzbar.`
: `Aktuell 0 kostenlose Bildmodelle bei OpenRouter; ${diagnostics.image_model_count || 0} kostenpflichtige Bildmodelle erkannt. Auto nutzt OpenAI als Fallback, sofern ein OpenAI-Key hinterlegt ist.`;
}
} catch {}
}
} catch (err) {
state.openRouterImageModels = [];
renderOpenRouterModelPreview([], selectedId, freeOnly);
if (status) status.textContent = `OpenRouter-Modelle konnten nicht geladen werden: ${err.message}`;
}
}
function populateSettingsForm(settings, platforms, providers) {
const ps = document.getElementById('setting-default-platform');
const fs = document.getElementById('filter-platform');
const as = document.getElementById('setting-default-ai');
ps.innerHTML = ''; fs.innerHTML = '<option value="">Alle Plattformen</option>';
for (const p of platforms) {
ps.innerHTML += `<option value="${p.id}">${p.name}</option>`;
fs.innerHTML += `<option value="${p.id}">${p.name}</option>`;
}
as.innerHTML = providers.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
ps.value = settings.default_platform || 'vinted';
as.value = settings.default_ai || 'claude';
document.getElementById('setting-default-language').value = settings.default_language || 'de';
document.getElementById('setting-seller-notes').value = settings.seller_notes || '';
document.getElementById('setting-anthropic-key').placeholder = settings.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...';
document.getElementById('setting-openai-key').placeholder = settings.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...';
document.getElementById('setting-openrouter-key').placeholder = settings.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...';
document.getElementById('setting-etsy-key').placeholder = settings.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx';
document.getElementById('setting-ebay-client-id').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...';
document.getElementById('setting-ebay-client-secret').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...';
document.getElementById('setting-ebay-sandbox').checked = !!settings.ebay_sandbox;
document.getElementById('setting-ollama-url').value = settings.ollama_url || 'http://localhost:11434';
document.getElementById('setting-public-base-url').value = settings.public_base_url || '';
detectPublicBaseUrl({ apply: !settings.public_base_url });
document.getElementById('setting-ai-model-photo-provider').value = settings.ai_model_photo_provider || 'auto';
const savedPhotoMode = ['ghost_mannequin', 'flatlay'].includes(settings.ai_model_photo_default_mode) ? settings.ai_model_photo_default_mode : 'flatlay';
document.getElementById('setting-ai-model-photo-mode').value = savedPhotoMode;
document.getElementById('setting-ai-model-photo-variants').value = String(settings.ai_model_photo_default_variants || '1');
document.getElementById('setting-ai-model-photo-preset').value = settings.ai_model_photo_default_preset || 'mixed';
document.getElementById('setting-ai-model-photo-enabled').checked = String(settings.ai_model_photos_enabled || '1') === '1';
document.getElementById('setting-ai-model-photo-free-only').checked = String(settings.ai_model_photo_openrouter_free_only || '1') === '1';
document.getElementById('setting-ai-model-photo-block-underwear').checked = String(settings.ai_model_photo_block_underwear || '1') === '1';
document.getElementById('setting-ai-model-photo-block-kids').checked = String(settings.ai_model_photo_block_kids || '1') === '1';
document.getElementById('setting-ai-model-photo-preserve-logos').checked = String(settings.ai_model_photo_preserve_logos || '1') === '1';
getAiPromptDraft('generator').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1';
getAiPromptDraft('detail').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1';
document.getElementById('setting-ai-model-photo-openai-model').value = settings.ai_model_photo_openai_model || 'gpt-image-1';
document.getElementById('setting-ai-model-photo-local-url').value = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188';
document.getElementById('setting-ai-model-photo-local-token').placeholder = settings.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)';
document.getElementById('setting-ai-model-photo-local-workflow-path').value = settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json';
document.getElementById('setting-ai-model-photo-local-install-path').value = settings.ai_model_photo_local_install_path || 'local-ai/comfyui';
document.getElementById('setting-ai-model-photo-local-enabled').checked = String(settings.ai_model_photo_local_enabled || '1') === '1';
document.getElementById('setting-ai-model-photo-local-require-token').checked = String(settings.ai_model_photo_local_require_token || '1') === '1';
document.getElementById('setting-ai-model-photo-use-source-reference').checked = String(settings.ai_model_photo_use_source_reference || '1') === '1';
document.getElementById('setting-ai-model-photo-reference-strength').value = String(settings.ai_model_photo_reference_strength || '70');
document.getElementById('setting-ai-model-photo-local-resolution').value = String(settings.ai_model_photo_local_resolution || '768');
document.getElementById('setting-flux-runtime-profile').value = settings.flux_runtime_profile || 'auto';
document.getElementById('setting-flux-default-profile').value = settings.flux_default_profile || 'balanced';
document.getElementById('setting-flux-default-style').value = settings.flux_default_style || 'photorealistic';
document.getElementById('setting-flux-default-format').value = settings.flux_default_format || '768x768';
document.getElementById('setting-flux-default-variants').value = settings.flux_default_variants || '1';
document.getElementById('setting-flux-max-parallel-jobs').value = settings.flux_max_parallel_jobs || '1';
document.getElementById('setting-flux-prompt-assistant').checked = String(settings.flux_prompt_assistant || '1') === '1';
document.getElementById('setting-flux-seed-lock').checked = String(settings.flux_seed_lock || '0') === '1';
const studioStyle = document.getElementById('flux-studio-style'); if (studioStyle) studioStyle.value = settings.flux_default_style || 'photorealistic';
const studioFormat = document.getElementById('flux-studio-format'); if (studioFormat) studioFormat.value = settings.flux_default_format || '768x768';
const studioLock = document.getElementById('flux-studio-seed-lock'); if (studioLock) studioLock.checked = String(settings.flux_seed_lock || '0') === '1';
applyFluxProfile(settings.flux_default_profile || 'balanced', { persist: false });
setFluxVariantCount(settings.flux_default_variants || '1', { persist: false });
updateReferenceStrengthUi();
const aiStatusText = document.getElementById('settings-ai-status-text');
const aiStatusChip = document.getElementById('settings-ai-status-chip');
if (aiStatusText) aiStatusText.textContent = String(settings.ai_model_photos_enabled || '1') === '1' ? 'Aktiv' : 'Deaktiviert';
if (aiStatusChip) aiStatusChip.className = `settings-status-chip ${String(settings.ai_model_photos_enabled || '1') === '1' ? 'is-running' : 'is-disabled'}`;
updateAiPhotoProviderSettingsUi();
loadOpenRouterImageModels(settings.ai_model_photo_openrouter_model || '');
testLocalImageProvider();
const etsyWrap = document.getElementById('etsy-connect-wrap');
const etsyBadge = document.getElementById('etsy-status-badge');
if (settings.has_etsy_key) {
etsyWrap.classList.remove('hidden');
if (settings.etsy_connected) {
etsyBadge.textContent = 'Verbunden';
etsyBadge.className = 'etsy-status connected';
} else {
etsyBadge.textContent = 'Nicht verbunden';
etsyBadge.className = 'etsy-status disconnected';
}
} else {
etsyWrap.classList.add('hidden');
}
const ebayWrap = document.getElementById('ebay-connect-wrap');
const ebayBadge = document.getElementById('ebay-status-badge');
if (settings.has_ebay_credentials) {
ebayWrap.classList.remove('hidden');
if (settings.ebay_connected) {
ebayBadge.textContent = settings.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden';
ebayBadge.className = 'etsy-status connected';
} else {
ebayBadge.textContent = 'Nicht verbunden';
ebayBadge.className = 'etsy-status disconnected';
}
} else {
ebayWrap.classList.add('hidden');
}
}
function setupSettings() {
setupSettingsNavigation();
updateReferenceStrengthUi();
watchLocalFluxInstallStatus();
document.getElementById('detect-public-base-url-btn')?.addEventListener('click', () => detectPublicBaseUrl({ apply: true }));
document.getElementById('setting-ai-model-photo-provider')?.addEventListener('change', updateAiPhotoProviderSettingsUi);
document.getElementById('setting-ai-model-photo-free-only')?.addEventListener('change', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || ''));
document.getElementById('refresh-openrouter-models-btn')?.addEventListener('click', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || ''));
document.getElementById('test-local-image-provider-btn')?.addEventListener('click', testLocalImageProvider);
document.getElementById('install-local-image-provider-btn')?.addEventListener('click', installLocalImageProvider);
document.getElementById('update-local-image-provider-btn')?.addEventListener('click', updateLocalImageProvider);
document.getElementById('start-local-image-provider-btn')?.addEventListener('click', startLocalImageProvider);
document.getElementById('stop-local-image-provider-btn')?.addEventListener('click', stopLocalImageProvider);
document.getElementById('regenerate-local-image-token-btn')?.addEventListener('click', regenerateLocalImageToken);
document.getElementById('uninstall-local-image-provider-btn')?.addEventListener('click', uninstallLocalImageProvider);
document.getElementById('setting-ai-model-photo-local-enabled')?.addEventListener('change', (event) => setLocalFluxEnabled(event.target.checked));
document.getElementById('setting-ai-model-photo-reference-strength')?.addEventListener('input', updateReferenceStrengthUi);
document.getElementById('setting-ai-model-photo-use-source-reference')?.addEventListener('change', updateReferenceStrengthUi);
document.getElementById('setting-ai-model-photo-enabled')?.addEventListener('change', (event) => {
const text = document.getElementById('settings-ai-status-text');
const chip = document.getElementById('settings-ai-status-chip');
if (text) text.textContent = event.target.checked ? 'Aktiv' : 'Deaktiviert';
if (chip) chip.className = `settings-status-chip ${event.target.checked ? 'is-running' : 'is-disabled'}`;
});
document.querySelectorAll('#settings input, #settings select, #settings textarea').forEach(field => {
if (field.closest('#theme-manager-root')) return;
field.addEventListener('change', () => {
const title = document.getElementById('settings-save-title');
const copy = document.getElementById('settings-save-copy');
if (title) title.textContent = 'Ungespeicherte Änderungen';
if (copy) copy.textContent = 'Mit „Einstellungen speichern“ übernehmen.';
});
});
document.getElementById('save-settings-btn').addEventListener('click', async () => {
try {
const settings = {
default_platform: document.getElementById('setting-default-platform').value,
default_ai: document.getElementById('setting-default-ai').value,
default_language: document.getElementById('setting-default-language').value,
seller_notes: document.getElementById('setting-seller-notes').value,
ollama_url: document.getElementById('setting-ollama-url').value,
public_base_url: document.getElementById('setting-public-base-url').value.trim(),
ai_model_photos_enabled: document.getElementById('setting-ai-model-photo-enabled').checked ? '1' : '0',
ai_model_photo_provider: document.getElementById('setting-ai-model-photo-provider').value,
ai_model_photo_default_mode: document.getElementById('setting-ai-model-photo-mode').value,
ai_model_photo_default_variants: document.getElementById('setting-ai-model-photo-variants').value,
ai_model_photo_default_preset: document.getElementById('setting-ai-model-photo-preset').value,
ai_model_photo_openrouter_free_only: document.getElementById('setting-ai-model-photo-free-only').checked ? '1' : '0',
ai_model_photo_block_underwear: document.getElementById('setting-ai-model-photo-block-underwear').checked ? '1' : '0',
ai_model_photo_block_kids: document.getElementById('setting-ai-model-photo-block-kids').checked ? '1' : '0',
ai_model_photo_preserve_logos: document.getElementById('setting-ai-model-photo-preserve-logos').checked ? '1' : '0',
ai_model_photo_openrouter_model: document.getElementById('setting-ai-model-photo-openrouter-model').value,
ai_model_photo_openai_model: document.getElementById('setting-ai-model-photo-openai-model').value.trim() || 'gpt-image-1',
ai_model_photo_local_enabled: document.getElementById('setting-ai-model-photo-local-enabled').checked ? '1' : '0',
ai_model_photo_local_url: document.getElementById('setting-ai-model-photo-local-url').value.trim() || 'http://127.0.0.1:8188',
ai_model_photo_local_workflow_path: document.getElementById('setting-ai-model-photo-local-workflow-path').value.trim() || 'local-ai/workflows/vendoo_flux_schnell_api.json',
ai_model_photo_local_install_path: document.getElementById('setting-ai-model-photo-local-install-path').value.trim() || 'local-ai/comfyui',
ai_model_photo_local_require_token: document.getElementById('setting-ai-model-photo-local-require-token').checked ? '1' : '0',
ai_model_photo_use_source_reference: document.getElementById('setting-ai-model-photo-use-source-reference').checked ? '1' : '0',
ai_model_photo_reference_strength: document.getElementById('setting-ai-model-photo-reference-strength').value || '70',
ai_model_photo_local_resolution: document.getElementById('setting-ai-model-photo-local-resolution').value || '768',
flux_runtime_profile: document.getElementById('setting-flux-runtime-profile').value || 'auto',
flux_default_profile: document.getElementById('setting-flux-default-profile').value || 'balanced',
flux_prompt_assistant: document.getElementById('setting-flux-prompt-assistant').checked ? '1' : '0',
flux_default_style: document.getElementById('setting-flux-default-style').value || 'photorealistic',
flux_default_format: document.getElementById('setting-flux-default-format').value || '768x768',
flux_default_variants: document.getElementById('setting-flux-default-variants').value || '1',
flux_seed_lock: document.getElementById('setting-flux-seed-lock').checked ? '1' : '0',
flux_max_parallel_jobs: document.getElementById('setting-flux-max-parallel-jobs').value || '1',
};
const ak = document.getElementById('setting-anthropic-key').value;
const ok = document.getElementById('setting-openai-key').value;
const ork = document.getElementById('setting-openrouter-key').value;
const ek = document.getElementById('setting-etsy-key').value;
const localToken = document.getElementById('setting-ai-model-photo-local-token').value.trim();
const ebayId = document.getElementById('setting-ebay-client-id').value;
const ebaySec = document.getElementById('setting-ebay-client-secret').value;
const ebayRu = document.getElementById('setting-ebay-ru-name').value;
const ebaySandbox = document.getElementById('setting-ebay-sandbox').checked;
if (ak) settings.anthropic_key = ak;
if (ok) settings.openai_key = ok;
if (ork) settings.openrouter_key = ork;
if (ek) settings.etsy_key = ek;
if (localToken) settings.ai_model_photo_local_token = localToken;
if (ebayId) settings.ebay_client_id = ebayId;
if (ebaySec) settings.ebay_client_secret = ebaySec;
if (ebayRu) settings.ebay_ru_name = ebayRu;
settings.ebay_sandbox = ebaySandbox ? 'true' : 'false';
const saved = await api('PUT', '/api/settings', settings);
state.platform = settings.default_platform;
state.aiProvider = settings.default_ai;
state.language = settings.default_language;
renderPlatformButtons();
renderProviderButtons();
document.getElementById('language-select').value = state.language;
document.getElementById('setting-anthropic-key').value = '';
document.getElementById('setting-openai-key').value = '';
document.getElementById('setting-openrouter-key').value = '';
document.getElementById('setting-etsy-key').value = '';
document.getElementById('setting-ai-model-photo-local-token').value = '';
document.getElementById('setting-ebay-client-id').value = '';
document.getElementById('setting-ebay-client-secret').value = '';
document.getElementById('setting-anthropic-key').placeholder = saved.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...';
document.getElementById('setting-openai-key').placeholder = saved.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...';
document.getElementById('setting-openrouter-key').placeholder = saved.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...';
document.getElementById('setting-etsy-key').placeholder = saved.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx';
document.getElementById('setting-ai-model-photo-local-token').placeholder = saved.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)';
document.getElementById('setting-ebay-client-id').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...';
document.getElementById('setting-ebay-client-secret').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...';
const etsyWrap = document.getElementById('etsy-connect-wrap');
if (saved.has_etsy_key) {
etsyWrap.classList.remove('hidden');
const badge = document.getElementById('etsy-status-badge');
if (saved.etsy_connected) { badge.textContent = 'Verbunden'; badge.className = 'etsy-status connected'; }
else { badge.textContent = 'Nicht verbunden'; badge.className = 'etsy-status disconnected'; }
}
updateAiPhotoProviderSettingsUi();
if (saved.public_base_url !== undefined) document.getElementById('setting-public-base-url').value = saved.public_base_url || '';
detectPublicBaseUrl({ apply: false });
loadOpenRouterImageModels(saved.ai_model_photo_openrouter_model || document.getElementById('setting-ai-model-photo-openrouter-model').value || '');
testLocalImageProvider();
const ebayWrap = document.getElementById('ebay-connect-wrap');
if (saved.has_ebay_credentials) {
ebayWrap.classList.remove('hidden');
const ebBadge = document.getElementById('ebay-status-badge');
if (saved.ebay_connected) {
ebBadge.textContent = saved.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden';
ebBadge.className = 'etsy-status connected';
} else {
ebBadge.textContent = 'Nicht verbunden';
ebBadge.className = 'etsy-status disconnected';
}
}
const saveTitle = document.getElementById('settings-save-title');
const saveCopy = document.getElementById('settings-save-copy');
if (saveTitle) saveTitle.textContent = 'Einstellungen gespeichert';
if (saveCopy) saveCopy.textContent = `Zuletzt gespeichert: ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
toast('Einstellungen gespeichert!');
} catch (err) { toast('Fehler: ' + err.message); }
});
document.getElementById('etsy-connect-btn').addEventListener('click', () => {
window.open('/auth/etsy', 'etsy-auth', 'width=600,height=700');
});
document.getElementById('ebay-connect-btn').addEventListener('click', () => {
window.open('/auth/ebay', 'ebay-auth', 'width=600,height=700');
});
window.addEventListener('message', async (e) => {
if (e.data === 'etsy-connected') {
const badge = document.getElementById('etsy-status-badge');
badge.textContent = 'Verbunden';
badge.className = 'etsy-status connected';
toast('Etsy erfolgreich verbunden!');
}
if (e.data === 'ebay-connected') {
const badge = document.getElementById('ebay-status-badge');
badge.textContent = 'Verbunden';
badge.className = 'etsy-status connected';
toast('eBay erfolgreich verbunden!');
}
});
document.querySelectorAll('.toggle-visibility').forEach(btn => {
btn.addEventListener('click', () => {
const inp = btn.previousElementSibling;
const isPw = inp.type === 'password';
inp.type = isPw ? 'text' : 'password';
btn.textContent = isPw ? 'Verbergen' : 'Zeigen';
});
});
}
// --- Category Picker ---
const catCache = {};
async function loadCategories(platformId) {
if (catCache[platformId]) return catCache[platformId];
try {
const data = await api('GET', `/api/categories/${platformId}`);
catCache[platformId] = data;
return data;
} catch { return { tree: [], flat: [] }; }
}
function createCategoryPicker(container, platformId, initialValue) {
const wrap = typeof container === 'string' ? document.getElementById(container) : container;
if (!wrap) return { getValue: () => null, setValue: () => {} };
let selected = null;
let dropdownOpen = false;
wrap.innerHTML = `
<div class="cat-picker-wrap">
<input type="text" class="cat-search meta-input" placeholder="Kategorie suchen...">
<div class="cat-dropdown"></div>
<div class="cat-selected-display" style="display:none"></div>
</div>`;
const input = wrap.querySelector('.cat-search');
const dropdown = wrap.querySelector('.cat-dropdown');
const selectedDisplay = wrap.querySelector('.cat-selected-display');
async function doSearch(q) {
const data = await loadCategories(platformId);
if (!q || q.length < 2) {
renderTree(data.tree);
return;
}
try {
const results = await api('GET', `/api/categories/${platformId}/match?q=${encodeURIComponent(q)}`);
renderResults(results);
} catch { renderResults([]); }
}
function renderResults(results) {
if (!results.length) {
dropdown.innerHTML = '<div class="cat-empty">Keine Kategorien gefunden</div>';
openDropdown();
return;
}
dropdown.innerHTML = results.map(r => `
<div class="cat-result-item" data-path="${escapeHtml(r.pathStr)}" data-name="${escapeHtml(r.name)}">
<div class="cat-result-name">${escapeHtml(r.name)}</div>
<div class="cat-result-path">${escapeHtml(r.pathStr)}</div>
</div>`).join('');
dropdown.querySelectorAll('.cat-result-item').forEach(el => {
el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name));
});
openDropdown();
}
function renderTree(tree) {
if (!tree || !tree.length) {
dropdown.innerHTML = '<div class="cat-empty">Keine Kategorien verfügbar</div>';
openDropdown();
return;
}
dropdown.innerHTML = tree.map(group => {
const children = (group.children || []).map(sub => {
if (sub.children) {
const leaves = sub.children.map(leaf =>
`<div class="cat-leaf" data-path="${escapeHtml([group.name, sub.name, leaf.name].join(' > '))}" data-name="${escapeHtml(leaf.name)}">${escapeHtml(leaf.name)}</div>`
).join('');
return `<div class="cat-sub">
<div class="cat-sub-hd">${escapeHtml(sub.name)}</div>
<div class="cat-sub-children">${leaves}</div>
</div>`;
}
return `<div class="cat-leaf" data-path="${escapeHtml([group.name, sub.name].join(' > '))}" data-name="${escapeHtml(sub.name)}">${escapeHtml(sub.name)}</div>`;
}).join('');
return `<div class="cat-group">
<div class="cat-group-hd" data-expanded="false">${escapeHtml(group.name)}</div>
<div class="cat-group-body" style="display:none">${children}</div>
</div>`;
}).join('');
dropdown.querySelectorAll('.cat-group-hd').forEach(hd => {
hd.addEventListener('click', () => {
const body = hd.nextElementSibling;
const expanded = hd.dataset.expanded === 'true';
hd.dataset.expanded = expanded ? 'false' : 'true';
body.style.display = expanded ? 'none' : 'block';
hd.classList.toggle('expanded', !expanded);
});
});
dropdown.querySelectorAll('.cat-leaf').forEach(el => {
el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name));
});
openDropdown();
}
function selectCategory(path, name) {
selected = path;
input.value = '';
input.style.display = 'none';
selectedDisplay.style.display = 'flex';
selectedDisplay.innerHTML = `
<span class="cat-sel-text">${escapeHtml(path)}</span>
<button class="cat-sel-clear" type="button">&times;</button>`;
selectedDisplay.querySelector('.cat-sel-clear').addEventListener('click', clearSelection);
closeDropdown();
}
function clearSelection() {
selected = null;
input.style.display = '';
selectedDisplay.style.display = 'none';
input.value = '';
input.focus();
}
function openDropdown() { dropdown.style.display = 'block'; dropdownOpen = true; }
function closeDropdown() { dropdown.style.display = 'none'; dropdownOpen = false; }
input.addEventListener('focus', () => doSearch(input.value));
input.addEventListener('input', debounce(() => doSearch(input.value), 200));
document.addEventListener('click', e => {
if (dropdownOpen && !wrap.contains(e.target)) closeDropdown();
});
if (initialValue) {
selectCategory(initialValue, initialValue.split(' > ').pop());
}
return {
getValue: () => selected,
setValue: (val) => {
if (val) selectCategory(val, val.split(' > ').pop());
else clearSelection();
},
};
}
let metaCategoryPicker = null;
function addQuillTooltips() {
const tooltips = {
'ql-bold': 'Fett', 'ql-italic': 'Kursiv', 'ql-underline': 'Unterstrichen',
'ql-strike': 'Durchgestrichen', 'ql-blockquote': 'Zitat', 'ql-link': 'Link einfügen',
'ql-clean': 'Formatierung entfernen', 'ql-list[value="ordered"]': 'Nummerierte Liste',
'ql-list[value="bullet"]': 'Aufzählung', 'ql-color': 'Textfarbe',
'ql-background': 'Hintergrundfarbe', 'ql-align': 'Ausrichtung', 'ql-header': 'Überschrift',
};
for (const [sel, tip] of Object.entries(tooltips)) {
document.querySelectorAll(`.ql-${sel}, .${sel}, button.${sel.split('[')[0]}`).forEach(el => { el.title = tip; });
}
document.querySelectorAll('.ql-bold').forEach(el => { el.title = 'Fett'; });
document.querySelectorAll('.ql-italic').forEach(el => { el.title = 'Kursiv'; });
document.querySelectorAll('.ql-underline').forEach(el => { el.title = 'Unterstrichen'; });
document.querySelectorAll('.ql-strike').forEach(el => { el.title = 'Durchgestrichen'; });
document.querySelectorAll('.ql-blockquote').forEach(el => { el.title = 'Zitat'; });
document.querySelectorAll('.ql-link').forEach(el => { el.title = 'Link einfügen'; });
document.querySelectorAll('.ql-clean').forEach(el => { el.title = 'Formatierung entfernen'; });
document.querySelectorAll('.ql-list[value="ordered"]').forEach(el => { el.title = 'Nummerierte Liste'; });
document.querySelectorAll('.ql-list[value="bullet"]').forEach(el => { el.title = 'Aufzählung'; });
document.querySelectorAll('.ql-color .ql-picker-label').forEach(el => { el.title = 'Textfarbe'; });
document.querySelectorAll('.ql-background .ql-picker-label').forEach(el => { el.title = 'Hintergrundfarbe'; });
document.querySelectorAll('.ql-align .ql-picker-label').forEach(el => { el.title = 'Ausrichtung'; });
document.querySelectorAll('.ql-header .ql-picker-label').forEach(el => { el.title = 'Überschrift'; });
}
function generateQRDataUrl(text, size) {
if (typeof QRious === 'undefined') return null;
try {
const qr = new QRious({ value: text, size: size || 120, level: 'M' });
return qr.toDataURL();
} catch { return null; }
}
let printStudioState = {
mode: 'labels',
listings: [],
listing: null,
copies: 1,
format: '90x50',
showPrice: true,
};
function ensurePrintStudio() {
let studio = document.getElementById('print-studio');
if (studio) return studio;
studio = document.createElement('div');
studio.id = 'print-studio';
studio.className = 'print-studio hidden';
studio.innerHTML = `
<div class="print-studio-backdrop" data-print-close></div>
<section class="print-studio-shell" role="dialog" aria-modal="true" aria-labelledby="print-studio-title">
<header class="print-studio-header">
<div>
<span class="print-studio-kicker">Vendoo Print Studio</span>
<h2 id="print-studio-title">Druckvorschau</h2>
</div>
<button type="button" class="print-studio-close" data-print-close aria-label="Druckvorschau schließen">×</button>
</header>
<div class="print-studio-layout">
<aside class="print-studio-controls">
<div class="print-control-group" id="print-mode-summary"></div>
<label class="print-control-field" id="print-format-field">
<span>Etikettenformat</span>
<select id="print-format-select">
<option value="90x50">90 × 50 mm · 10 pro A4</option>
<option value="62x29">62 × 29 mm · 27 pro A4</option>
</select>
</label>
<label class="print-control-field" id="print-copies-field">
<span>Exemplare je Artikel</span>
<input id="print-copies-input" type="number" min="1" max="20" value="1">
</label>
<label class="print-control-check" id="print-price-field">
<input id="print-show-price" type="checkbox" checked>
<span>Preis auf Etikett anzeigen</span>
</label>
<div class="print-hint">
<strong>Ohne neues Fenster</strong>
<span>Die Vorschau bleibt in Vendoo. Erst „Drucken“ öffnet den normalen Systemdruckdialog.</span>
</div>
</aside>
<div class="print-preview-pane">
<div class="print-preview-toolbar">
<span id="print-preview-count"></span>
<span>Maßstabsgetreue A4-Vorschau</span>
</div>
<div id="print-preview-pages" class="print-preview-pages"></div>
</div>
</div>
<footer class="print-studio-footer">
<button type="button" class="vd-button" data-print-close>Abbrechen</button>
<button type="button" class="vd-button primary" id="print-now-btn">Drucken</button>
</footer>
</section>
`;
document.body.appendChild(studio);
studio.querySelectorAll('[data-print-close]').forEach(button => {
button.addEventListener('click', closePrintStudio);
});
studio.querySelector('#print-format-select').addEventListener('change', event => {
printStudioState.format = event.target.value;
renderPrintStudio();
});
studio.querySelector('#print-copies-input').addEventListener('input', event => {
printStudioState.copies = Math.max(1, Math.min(20, Number(event.target.value) || 1));
renderPrintStudio();
});
studio.querySelector('#print-show-price').addEventListener('change', event => {
printStudioState.showPrice = event.target.checked;
renderPrintStudio();
});
studio.querySelector('#print-now-btn').addEventListener('click', () => {
document.body.classList.add('print-studio-active');
window.print();
});
window.addEventListener('afterprint', () => document.body.classList.remove('print-studio-active'));
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && !studio.classList.contains('hidden')) closePrintStudio();
});
return studio;
}
function closePrintStudio() {
const studio = document.getElementById('print-studio');
if (!studio) return;
studio.classList.add('hidden');
document.body.classList.remove('print-studio-open', 'print-studio-active');
}
function openPrintStudio(mode, payload) {
printStudioState = {
mode,
listings: payload.listings || [],
listing: payload.listing || null,
copies: 1,
format: '90x50',
showPrice: true,
};
const studio = ensurePrintStudio();
studio.classList.remove('hidden');
document.body.classList.add('print-studio-open');
studio.querySelector('#print-format-select').value = printStudioState.format;
studio.querySelector('#print-copies-input').value = '1';
studio.querySelector('#print-show-price').checked = true;
renderPrintStudio();
}
function printLabels(listings) {
if (!Array.isArray(listings) || !listings.length) return;
openPrintStudio('labels', { listings });
}
function printDatasheet(listing) {
if (!listing) return;
openPrintStudio('datasheet', { listing });
}
function getPrintPlatformName(platform) {
return state.platforms.find(item => item.id === platform)?.name || platformLabel(platform);
}
function buildPrintLabel(listing) {
const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`;
const title = (listing.title || 'Ohne Titel').substring(0, 60);
const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 170);
const price = listing.price ? formatCurrency(listing.price) : '';
const platform = getPrintPlatformName(listing.platform);
return `
<article class="print-label-card">
<div class="print-label-accent"></div>
<div class="print-label-qr">${qr ? `<img src="${qr}" alt="QR-Code ${escapeHtml(sku)}">` : ''}</div>
<div class="print-label-copy">
<div class="print-label-brand">Vendoo</div>
<div class="print-label-sku">${escapeHtml(sku)}</div>
<div class="print-label-title">${escapeHtml(title)}</div>
<div class="print-label-meta">
<span>${escapeHtml(platform)}</span>
${printStudioState.showPrice && price ? `<strong>${escapeHtml(price)}</strong>` : ''}
</div>
</div>
</article>
`;
}
function buildPrintDatasheet(listing) {
const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`;
const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 190);
const photos = (listing.photos || []).slice(0, 4);
const dateValue = listing.created_at ? new Date(`${listing.created_at}Z`) : new Date();
const date = Number.isNaN(dateValue.getTime()) ? '' : dateValue.toLocaleDateString('de-DE');
const meta = [
['Marke', listing.brand],
['Kategorie', listing.category],
['Größe', listing.size],
['Farbe', listing.color],
['Zustand', listing.condition],
['Preis', listing.price ? formatCurrency(listing.price) : ''],
['Plattform', getPrintPlatformName(listing.platform)],
['Lagerort', listing.storage_location],
['Erstellt', date],
].filter(([, value]) => value);
return `
<article class="print-datasheet">
<header class="print-datasheet-header">
<div>
<span class="print-datasheet-brand">Vendoo · Artikeldatenblatt</span>
<h1>${escapeHtml(listing.title || 'Ohne Titel')}</h1>
<div class="print-datasheet-sku">${escapeHtml(sku)}</div>
</div>
${qr ? `<img class="print-datasheet-qr" src="${qr}" alt="QR-Code ${escapeHtml(sku)}">` : ''}
</header>
${photos.length ? `<div class="print-datasheet-photos">${photos.map(photo => `<div><img src="${escapeHtml(photoUrl(photo))}" alt=""></div>`).join('')}</div>` : ''}
${listing.description ? `<section class="print-datasheet-description"><h2>Beschreibung</h2><p>${escapeHtml(String(listing.description).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())}</p></section>` : ''}
<section class="print-datasheet-meta">
${meta.map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`).join('')}
</section>
</article>
`;
}
function chunkPrintItems(items, size) {
const chunks = [];
for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size));
return chunks;
}
function renderPrintStudio() {
const studio = ensurePrintStudio();
const pages = studio.querySelector('#print-preview-pages');
const summary = studio.querySelector('#print-mode-summary');
const count = studio.querySelector('#print-preview-count');
const formatField = studio.querySelector('#print-format-field');
const copiesField = studio.querySelector('#print-copies-field');
const priceField = studio.querySelector('#print-price-field');
const labelsMode = printStudioState.mode === 'labels';
formatField.classList.toggle('hidden', !labelsMode);
copiesField.classList.toggle('hidden', !labelsMode);
priceField.classList.toggle('hidden', !labelsMode);
if (!labelsMode) {
const listing = printStudioState.listing;
summary.innerHTML = `<span class="print-summary-icon">▤</span><div><strong>Datenblatt</strong><span>${escapeHtml(listing?.sku || listing?.title || 'Artikel')}</span></div>`;
count.textContent = '1 Seite';
pages.innerHTML = `<div class="print-page datasheet-page">${buildPrintDatasheet(listing)}</div>`;
return;
}
const expanded = [];
printStudioState.listings.forEach(listing => {
for (let copy = 0; copy < printStudioState.copies; copy += 1) expanded.push(listing);
});
const compact = printStudioState.format === '62x29';
const pageSize = compact ? 27 : 10;
const pageChunks = chunkPrintItems(expanded, pageSize);
summary.innerHTML = `<span class="print-summary-icon">⌗</span><div><strong>Etiketten</strong><span>${printStudioState.listings.length} Artikel · ${expanded.length} Etiketten</span></div>`;
count.textContent = `${pageChunks.length} ${pageChunks.length === 1 ? 'Seite' : 'Seiten'}`;
pages.innerHTML = pageChunks.map(chunk => `
<div class="print-page label-page ${compact ? 'compact-labels' : 'standard-labels'}">
<div class="print-label-grid">${chunk.map(buildPrintLabel).join('')}</div>
</div>
`).join('');
}
// --- Trash / Papierkorb ---
function setupTrash() {
document.getElementById('empty-trash-btn').addEventListener('click', async () => {
if (!confirm('Papierkorb endgültig leeren? Dies kann nicht rückgängig gemacht werden!')) return;
await api('DELETE', '/api/trash');
toast('Papierkorb geleert');
loadTrash();
updateTrashBadge();
});
}
async function loadTrash() {
try {
const listings = await api('GET', '/api/trash');
const c = document.getElementById('trash-list');
if (!listings.length) {
c.innerHTML = '<div class="empty-state">Papierkorb ist leer</div>';
return;
}
c.innerHTML = listings.map(l => {
const photo = l.photos?.[0] ? `<img class="thumb-small" src="/uploads/${l.photos[0]}" alt="">` : '<div class="thumb-small"></div>';
const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform;
const deletedDate = l.deleted_at ? new Date(l.deleted_at + 'Z').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
const priceStr = l.price ? `${l.price}€` : '';
return `<div class="trash-item" data-id="${l.id}">
${photo}
<div class="item-info">
<div class="item-title">${escapeHtml(l.title || 'Ohne Titel')}</div>
<div class="item-meta">${pName} · ${priceStr} · Gelöscht: ${deletedDate}</div>
</div>
<div class="trash-actions">
<button class="restore-btn" data-id="${l.id}">Wiederherstellen</button>
<button class="perm-delete-btn" data-id="${l.id}">Endgültig löschen</button>
</div>
</div>`;
}).join('');
c.querySelectorAll('.restore-btn').forEach(btn => {
btn.addEventListener('click', async () => {
await api('POST', `/api/trash/${btn.dataset.id}/restore`);
toast('Listing wiederhergestellt');
loadTrash();
updateTrashBadge();
});
});
c.querySelectorAll('.perm-delete-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!confirm('Listing endgültig löschen? Dies kann nicht rückgängig gemacht werden!')) return;
await api('DELETE', `/api/trash/${btn.dataset.id}`);
toast('Endgültig gelöscht');
loadTrash();
updateTrashBadge();
});
});
} catch (err) { toast('Fehler: ' + err.message); }
}
async function updateTrashBadge() {
try {
const data = await api('GET', '/api/trash/count');
const badge = document.getElementById('trash-badge');
if (data.count > 0) {
badge.textContent = data.count;
badge.classList.remove('hidden');
} else {
badge.classList.add('hidden');
}
} catch {}
}
// --- Utils ---
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
setTimeout(() => el.classList.remove('show'), 2500);
}
function debounce(fn, ms) {
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
}
function escapeHtml(str) {
const d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
// --- Dark Mode ---
function setupDarkMode() {
const saved = localStorage.getItem('vendoo-theme');
if (window.VendooTheme) {
window.VendooTheme.setTheme(saved === 'light' || saved === 'dark' || saved === 'contrast' ? saved : 'system', { persist: false });
} else if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.setAttribute('data-theme', 'dark');
}
updateThemeIcon();
document.getElementById('theme-toggle').addEventListener('click', () => {
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
const nextTheme = isDark ? 'light' : 'dark';
if (window.VendooTheme) window.VendooTheme.setTheme(nextTheme);
else {
document.documentElement.setAttribute('data-theme', nextTheme);
localStorage.setItem('vendoo-theme', nextTheme);
}
updateThemeIcon();
});
}
function updateThemeIcon() {
const btn = document.getElementById('theme-toggle');
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
btn.innerHTML = isDark
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
}
// --- Dashboard & Studio Flow ---
const STUDIO_STAGES = [
{ key: 'ready', label: 'Bereit', state: 'Prüfbereit', tone: 'success' },
{ key: 'scheduled', label: 'Geplant', state: 'Geplant', tone: 'warning' },
{ key: 'published', label: 'Veröffentlicht', state: 'Live', tone: 'success' },
{ key: 'sold', label: 'Verkauft', state: 'Verkauft', tone: 'success' },
];
let studioWorkflowData = null;
let selectedStudioListingId = null;
let todayFilter = 'all';
let dashboardAnalyticsData = null;
let dashboardResizeObserver = null;
let dashboardResizeTimer = null;
let dashboardLastChartWidth = 0;
function formatCurrency(value) {
if (value === null || value === undefined || value === '') return '';
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value) || 0);
}
function platformLabel(platform) {
return ({ vinted: 'Vinted', 'ebay-ka': 'Kleinanzeigen', 'ebay-de': 'eBay', etsy: 'Etsy', klekt: 'KLEKT' })[platform] || platform || '—';
}
function photoUrl(photo) {
if (!photo) return '';
return `/uploads/${String(photo).split('/').map(encodeURIComponent).join('/')}`;
}
function relativeTime(value) {
if (!value) return '';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '';
const diff = Math.max(0, Date.now() - date.getTime());
const minutes = Math.floor(diff / 60000);
if (minutes < 1) return 'Gerade eben';
if (minutes < 60) return `vor ${minutes} Min.`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `vor ${hours} Std.`;
const days = Math.floor(hours / 24);
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function activityGlyph(type) {
return ({ sold: '✓', published: '↗', failed: '!', scheduled: '◷', copied: '⧉', pending: '·', created: '+' })[type] || '·';
}
function setupAnalyticsDashboard() {
document.getElementById('dashboard-period')?.addEventListener('change', loadAnalyticsDashboard);
document.getElementById('dashboard-refresh')?.addEventListener('click', loadAnalyticsDashboard);
const dashboardPage = document.getElementById('dashboard');
const redrawDashboard = () => {
if (!dashboardAnalyticsData || !dashboardPage?.classList.contains('active')) return;
clearTimeout(dashboardResizeTimer);
dashboardResizeTimer = setTimeout(() => {
const width = document.getElementById('dashboard-line-chart')?.clientWidth || 0;
if (Math.abs(width - dashboardLastChartWidth) < 12) return;
dashboardLastChartWidth = width;
renderDashboardLineChart(dashboardAnalyticsData.timeline || []);
renderDashboardBars(dashboardAnalyticsData.platformRevenue || []);
renderDashboardCategories(dashboardAnalyticsData.categoryRevenue || []);
}, 90);
};
if ('ResizeObserver' in window && dashboardPage) {
dashboardResizeObserver?.disconnect();
dashboardResizeObserver = new ResizeObserver(redrawDashboard);
dashboardResizeObserver.observe(dashboardPage);
} else {
window.addEventListener('resize', redrawDashboard, { passive: true });
}
document.querySelectorAll('[data-dashboard-action]').forEach(button => button.addEventListener('click', async () => {
const action = button.dataset.dashboardAction;
if (action === 'new') return switchToTab('generator');
if (action === 'qr') {
switchToTab('generator');
setTimeout(() => document.getElementById('generator-mobile-upload')?.click(), 160);
return;
}
if (action === 'publish') return switchToTab('publish');
if (action === 'template') return switchToTab('templates');
if (action === 'photos') {
try {
const listings = await api('GET', '/api/listings');
const photos = listings.flatMap(item => item.photos || []).slice(0, 60);
await downloadPublishPhotos(photos);
} catch (error) { toast(`Fotos konnten nicht geladen werden: ${error.message}`); }
}
}));
}
function setupTodayFlow() {
document.querySelectorAll('[data-today-filter]').forEach(button => button.addEventListener('click', () => {
todayFilter = button.dataset.todayFilter || 'all';
document.querySelectorAll('.today-filter-tabs [data-today-filter]').forEach(tab => tab.classList.toggle('active', tab.dataset.todayFilter === todayFilter));
renderTodayReady(studioWorkflowData);
if (todayFilter === 'attention') document.querySelector('.attention-panel')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
}));
document.getElementById('today-sort')?.addEventListener('change', () => renderTodayReady(studioWorkflowData));
}
function dashboardSetText(id, value) {
const element = document.getElementById(id);
if (element) element.textContent = value;
}
function renderSparkline(id, values, tone = 'brand') {
const svg = document.getElementById(id);
if (!svg) return;
const raw = (values || []).map(Number).filter(Number.isFinite);
const nums = raw.length > 1 ? raw : [raw[0] || 0, raw[0] || 0];
const max = Math.max(...nums, 1);
const min = Math.min(...nums, 0);
const range = Math.max(1, max - min);
const points = nums.map((value, index) => `${(index / Math.max(1, nums.length - 1)) * 96 + 2},${24 - ((value - min) / range) * 18}`).join(' ');
const last = points.split(' ').at(-1)?.split(',') || ['98', '14'];
svg.setAttribute('viewBox', '0 0 100 28');
svg.setAttribute('preserveAspectRatio', 'none');
svg.innerHTML = `<polyline points="${points}" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${last[0]}" cy="${last[1]}" r="2.2" fill="currentColor"/>`;
svg.dataset.tone = tone;
}
function renderDashboardLineChart(timeline) {
const container = document.getElementById('dashboard-line-chart');
if (!container) return;
const values = timeline.map(item => Number(item.revenue) || 0);
const max = Math.max(...values, 1);
const measuredWidth = Math.round(container.getBoundingClientRect().width || container.clientWidth || 720);
const width = Math.max(320, measuredWidth);
const height = width < 520 ? 220 : 250;
const left = width < 520 ? 38 : 48;
const right = 14;
const top = 16;
const bottom = 34;
const chartW = width - left - right;
const chartH = height - top - bottom;
const points = values.map((value, index) => {
const x = left + (index / Math.max(1, values.length - 1)) * chartW;
const y = top + chartH - (value / max) * chartH;
return { x, y, value, item: timeline[index] };
});
const line = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
const area = `${left},${top + chartH} ${line} ${left + chartW},${top + chartH}`;
const yGrid = [0, .25, .5, .75, 1].map(part => {
const y = top + chartH - part * chartH;
return `<line x1="${left}" y1="${y}" x2="${left + chartW}" y2="${y}"/><text x="${left - 7}" y="${y + 4}">${Math.round(max * part).toLocaleString('de-DE')} €</text>`;
}).join('');
const step = Math.max(1, Math.floor(timeline.length / 5));
const labels = timeline.map((item, index) => index % step === 0 || index === timeline.length - 1
? `<text x="${points[index].x}" y="${height - 8}" text-anchor="middle">${new Date(item.date).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })}</text>` : '').join('');
container.innerHTML = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet"><g class="chart-grid">${yGrid}${labels}</g><polygon class="chart-area" points="${area}"/><polyline class="chart-line" points="${line}"/>${points.map(point => `<circle class="chart-point" cx="${point.x}" cy="${point.y}" r="4" tabindex="0"><title>${new Date(point.item.date).toLocaleDateString('de-DE')}: ${formatCurrency(point.value)}</title></circle>`).join('')}</svg>`;
dashboardLastChartWidth = measuredWidth;
}
function renderDashboardBars(items) {
const container = document.getElementById('dashboard-platform-chart');
if (!container) return;
const data = items.length ? items.slice(0, 5) : [{ platform: 'Keine Verkäufe', revenue: 0 }];
const max = Math.max(...data.map(item => Number(item.revenue) || 0), 1);
container.innerHTML = data.map(item => `<div class="dashboard-bar-item" title="${escapeHtml(platformLabel(item.platform))}: ${escapeHtml(formatCurrency(item.revenue))}"><strong>${escapeHtml(formatCurrency(item.revenue))}</strong><div><i style="height:${Math.max(5, (Number(item.revenue) / max) * 100)}%"></i></div><span>${escapeHtml(platformLabel(item.platform))}</span></div>`).join('');
}
function renderDashboardCategories(items) {
const container = document.getElementById('dashboard-category-chart');
if (!container) return;
const data = items.length ? items : [{ category: 'Keine Verkäufe', revenue: 1 }];
const total = data.reduce((sum, item) => sum + (Number(item.revenue) || 0), 0) || 1;
let cursor = 0;
const colors = ['#49a66f', '#3b82dc', '#8054b3', '#e87334', '#e4ab2f', '#b9b6b0'];
const stops = data.map((item, index) => {
const start = cursor;
cursor += ((Number(item.revenue) || 0) / total) * 100;
return `${colors[index % colors.length]} ${start}% ${cursor}%`;
}).join(',');
container.innerHTML = `<div class="dashboard-donut" style="background:conic-gradient(${stops})"><div><strong>${escapeHtml(formatCurrency(total))}</strong><span>Gesamt</span></div></div><div class="dashboard-category-legend">${data.map((item, index) => `<div><i style="background:${colors[index % colors.length]}"></i><span>${escapeHtml(item.category)}</span><strong>${Math.round((Number(item.revenue) || 0) / total * 1000) / 10}%</strong></div>`).join('')}</div>`;
}
function renderDashboardFocus(focus) {
const container = document.getElementById('dashboard-focus');
if (!container) return;
const items = [
['ready', focus.ready, 'Artikel bereit zum Veröffentlichen', 'history'],
['price', focus.priceChecks, 'Preisaktualisierungen empfohlen', 'history'],
['failed', focus.failedPublishes, 'fehlgeschlagene Publish-Versuche', 'publish'],
['uploads', focus.mobileUploads, 'neue Handy-Foto-Uploads', 'generator'],
['locations', focus.activeLocations, 'aktive Lagerorte', 'inventory'],
];
container.innerHTML = items.map(([tone, count, label, tab]) => `<button class="dashboard-focus-item tone-${tone}" type="button" data-focus-tab="${tab}"><strong>${Number(count) || 0}</strong><span>${escapeHtml(label)}</span><em>Anzeigen</em></button>`).join('');
container.querySelectorAll('[data-focus-tab]').forEach(button => button.addEventListener('click', () => switchToTab(button.dataset.focusTab)));
}
function renderDashboardTopSellers(items) {
const body = document.getElementById('dashboard-top-sellers');
if (!body) return;
body.innerHTML = items.length ? items.map(item => `<tr data-dashboard-listing="${item.id}"><td><div class="dashboard-product">${item.photo ? `<img src="${escapeHtml(photoUrl(item.photo))}" alt="">` : '<span></span>'}<div><strong>${escapeHtml(item.title || 'Ohne Titel')}</strong><small>${escapeHtml([item.color, item.size].filter(Boolean).join(' / '))}</small></div></div></td><td>${escapeHtml(item.sku || '')}</td><td>${escapeHtml(platformLabel(item.platform))}</td><td>${escapeHtml(formatCurrency(item.revenue))}</td><td>${item.sales || 1}</td><td><span class="dashboard-status">Verkauft</span></td></tr>`).join('') : '<tr><td colspan="6" class="dashboard-empty">Noch keine Verkäufe vorhanden.</td></tr>';
body.querySelectorAll('[data-dashboard-listing]').forEach(row => row.addEventListener('click', () => showDetail(Number(row.dataset.dashboardListing))));
}
function renderDashboardFeed(id, items, isNotification = false) {
const container = document.getElementById(id);
if (!container) return;
if (!items?.length) { container.innerHTML = '<div class="dashboard-empty">Keine Einträge</div>'; return; }
container.innerHTML = items.slice(0, 6).map(item => `<button type="button" data-feed-listing="${item.listingId || ''}"><span class="dashboard-feed-icon tone-${item.severity || item.type || 'info'}">${isNotification ? '!' : activityGlyph(item.type)}</span><span><strong>${escapeHtml(item.text || item.message || item.title || 'Aktivität')}</strong><small>${escapeHtml(relativeTime(item.at || item.createdAt))}</small></span></button>`).join('');
container.querySelectorAll('[data-feed-listing]').forEach(button => button.addEventListener('click', () => { const idValue = Number(button.dataset.feedListing); if (idValue) showDetail(idValue); }));
}
async function loadAnalyticsDashboard() {
const period = Number(document.getElementById('dashboard-period')?.value || 30);
const refresh = document.getElementById('dashboard-refresh');
refresh?.classList.add('is-loading');
try {
const data = await api('GET', `/api/dashboard/analytics?days=${period}`);
dashboardAnalyticsData = data;
dashboardSetText('dash-revenue-today', formatCurrency(data.kpis.revenueToday));
dashboardSetText('dash-sales-week', String(data.kpis.sales7Days));
dashboardSetText('dash-active-listings', String(data.kpis.activeListings));
dashboardSetText('dash-drafts', String(data.kpis.drafts));
dashboardSetText('dash-inventory-value', formatCurrency(data.kpis.inventoryValue));
dashboardSetText('dash-publish-rate', `${data.kpis.publishSuccessRate.toLocaleString('de-DE')}%`);
dashboardSetText('dash-period-revenue', formatCurrency(data.timeline.reduce((sum, item) => sum + Number(item.revenue || 0), 0)));
dashboardSetText('dash-period-label', `Letzte ${data.periodDays} Tage`);
dashboardSetText('dash-platform-total', formatCurrency(data.platformRevenue.reduce((sum, item) => sum + Number(item.revenue || 0), 0)));
const timelineValues = data.timeline.map(item => item.revenue);
renderSparkline('spark-revenue', timelineValues, 'green');
renderSparkline('spark-sales', data.timeline.map(item => item.sales), 'blue');
renderSparkline('spark-active', timelineValues.map((_, index) => Math.max(0, data.kpis.activeListings - data.timeline.length + index)), 'purple');
renderSparkline('spark-drafts', timelineValues.map((_, index) => Math.max(0, data.kpis.drafts + Math.sin(index) * 2)), 'orange');
renderSparkline('spark-inventory', timelineValues.map((value, index) => data.kpis.inventoryValue * .9 + index * (data.kpis.inventoryValue * .1 / Math.max(1, timelineValues.length))), 'green');
renderSparkline('spark-publish', timelineValues.map((_, index) => Math.max(0, data.kpis.publishSuccessRate - 4 + index / 3)), 'blue');
renderDashboardLineChart(data.timeline);
renderDashboardBars(data.platformRevenue);
renderDashboardCategories(data.categoryRevenue);
renderDashboardFocus(data.focus);
renderDashboardTopSellers(data.topSellers);
renderDashboardFeed('dashboard-activity', data.activity);
renderDashboardFeed('dashboard-notifications', data.notifications, true);
dashboardSetText('dashboard-quality-value', `${data.kpis.quality}%`);
dashboardSetText('dashboard-quality-label', data.kpis.quality >= 85 ? 'Sehr hoch' : data.kpis.quality >= 65 ? 'Gut, mit Potenzial' : 'Optimierung empfohlen');
document.getElementById('dashboard-quality-ring')?.style.setProperty('--confidence', `${data.kpis.quality}%`);
dashboardSetText('dashboard-system-state', data.system.healthy ? 'Alle Systeme sind betriebsbereit' : 'Prüfung erforderlich');
dashboardSetText('dashboard-system-copy', `${data.system.queuePending} Queue-Einträge ausstehend · ${data.system.scheduled} Veröffentlichungen geplant.`);
} catch (error) {
toast(`Dashboard konnte nicht geladen werden: ${error.message}`);
} finally { refresh?.classList.remove('is-loading'); }
}
function getStudioItem(id) {
if (!studioWorkflowData?.stages) return null;
for (const stage of Object.values(studioWorkflowData.stages)) {
const found = stage.items?.find(item => item.id === id);
if (found) return found;
}
return null;
}
function currentTodayItems(data) {
if (!data?.stages) return [];
const key = todayFilter === 'all' ? 'ready' : todayFilter;
if (key === 'attention') return [...(data.stages.captured?.items || []), ...(data.stages.ai_review?.items || [])];
return data.stages[key]?.items || [];
}
function renderTodaySummary(data) {
const set = (id, value) => { const element = document.getElementById(id); if (element) element.textContent = String(value || 0); };
set('today-count-ready', data.stages?.ready?.count);
set('today-count-scheduled', data.stages?.scheduled?.count);
set('today-count-published', data.stages?.published?.count);
set('today-count-sold', data.stages?.sold?.count);
set('today-count-attention', data.attentionCount);
set('today-ready-total', data.stages?.ready?.count);
set('today-scheduled-total', data.stages?.scheduled?.count);
}
function renderTodayReady(data) {
const container = document.getElementById('today-ready-grid');
if (!container || !data) return;
const labels = { all: 'Bereit für den nächsten Schritt', ready: 'Bereit für den nächsten Schritt', scheduled: 'Geplante Artikel', published: 'Veröffentlichte Artikel', sold: 'Verkaufte Artikel', attention: 'Artikel mit Prüfbedarf' };
const heading = container.closest('.work-panel')?.querySelector('h3');
if (heading) heading.textContent = labels[todayFilter] || labels.all;
let items = [...currentTodayItems(data)];
const sort = document.getElementById('today-sort')?.value || 'priority';
if (sort === 'newest') items.sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)));
if (sort === 'price') items.sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0));
items = items.slice(0, 4);
if (!items.length) { container.innerHTML = '<div class="empty-state">Für diesen Filter sind keine Artikel vorhanden.</div>'; return; }
container.innerHTML = items.map(item => `<button class="today-product-card${selectedStudioListingId === item.id ? ' selected' : ''}" type="button" data-studio-listing="${item.id}"><div class="today-product-media">${item.photos?.[0] ? `<img src="${escapeHtml(photoUrl(item.photos[0]))}" alt="${escapeHtml(item.title || 'Produktbild')}" decoding="async" draggable="false">` : '<span class="today-image-placeholder">Kein Bild</span>'}<b>${escapeHtml(platformLabel(item.platform))}</b></div><div class="today-product-copy"><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.size || item.color || item.sku || '')}</span><div><em>${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}</em><small>${Math.max(0, item.completeness || 0)}%</small></div></div></button>`).join('');
container.querySelectorAll('.today-product-media img').forEach(image => image.addEventListener('error', () => {
image.hidden = true;
const placeholder = document.createElement('span');
placeholder.className = 'today-image-placeholder';
placeholder.textContent = 'Bild nicht verfügbar';
image.parentElement?.prepend(placeholder);
}, { once: true }));
container.querySelectorAll('[data-studio-listing]').forEach(card => card.addEventListener('click', () => selectStudioListing(Number(card.dataset.studioListing))));
}
function renderTodayScheduled(data) {
const body = document.getElementById('today-scheduled-list');
if (!body) return;
const items = data.stages?.scheduled?.items?.slice(0, 4) || [];
body.innerHTML = items.length ? items.map(item => `<tr><td><button type="button" data-scheduled-id="${item.id}"><span>${item.photos?.[0] ? `<img src="${escapeHtml(photoUrl(item.photos[0]))}" alt="">` : ''}</span><div><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.size || item.sku || '')}</small></div></button></td><td>${escapeHtml(platformLabel(item.platform))}</td><td>${item.scheduledAt ? new Date(item.scheduledAt.replace(' ', 'T')).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td><td><span class="today-status">Geplant</span></td><td><button class="vd-button compact" type="button" data-scheduled-id="${item.id}">Vorschau</button></td></tr>`).join('') : '<tr><td colspan="5" class="dashboard-empty">Keine geplanten Veröffentlichungen.</td></tr>';
body.querySelectorAll('[data-scheduled-id]').forEach(button => button.addEventListener('click', () => selectStudioListing(Number(button.dataset.scheduledId))));
}
function renderStudioAttention(data) {
const list = document.getElementById('studio-attention');
const count = document.getElementById('attention-count');
if (count) count.textContent = String(data.attentionCount || 0);
if (!list) return;
if (!data.attention?.length) { list.innerHTML = '<div class="empty-state">Alles erledigt aktuell ist keine Aktion nötig.</div>'; return; }
const grouped = new Map();
for (const item of data.attention) {
const current = grouped.get(item.type) || { ...item, count: 0 };
current.count += 1;
grouped.set(item.type, current);
}
list.innerHTML = [...grouped.values()].slice(0, 5).map(item => `<button class="attention-item" type="button" data-attention-id="${item.listingId}" data-attention-type="${escapeHtml(item.type)}"><span class="attention-symbol">${item.severity === 'danger' ? '!' : '○'}</span><span class="attention-copy"><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(item.action)}</small></span><b>${item.count}</b></button>`).join('');
list.querySelectorAll('[data-attention-id]').forEach(button => button.addEventListener('click', () => {
const id = Number(button.dataset.attentionId);
if (button.dataset.attentionType === 'publish_failed') switchToTab('publish');
else showDetail(id);
}));
}
function renderStudioActivity(data) {
const recent = document.getElementById('studio-activity');
const completed = document.getElementById('studio-completed');
const render = (container, items) => {
if (!container) return;
container.innerHTML = items.length ? items.map(item => `<button class="activity-item" type="button" data-activity-listing="${item.listingId || ''}"><span class="activity-icon">${activityGlyph(item.type)}</span><span class="activity-copy">${escapeHtml(item.text)}</span><span class="activity-time">${escapeHtml(relativeTime(item.at))}</span></button>`).join('') : '<div class="empty-state">Noch keine Aktivität</div>';
container.querySelectorAll('[data-activity-listing]').forEach(button => button.addEventListener('click', () => { const id = Number(button.dataset.activityListing); if (id) selectStudioListing(id); }));
};
const completeTypes = new Set(['published', 'sold']);
render(recent, data.activity.filter(item => !completeTypes.has(item.type)).slice(0, 4));
render(completed, data.activity.filter(item => completeTypes.has(item.type)).slice(0, 4));
}
function selectStudioListing(id) {
const item = getStudioItem(id);
if (!item) return;
selectedStudioListingId = id;
document.querySelectorAll('[data-studio-listing]').forEach(card => card.classList.toggle('selected', Number(card.dataset.studioListing) === id));
renderStudioInspector(item);
}
function renderStudioInspector(item) {
const empty = document.getElementById('studio-inspector-empty');
const content = document.getElementById('studio-inspector-content');
if (!content || !empty) return;
empty.classList.add('hidden');
content.classList.remove('hidden');
const photos = item.photos || [];
const hero = photos[0] ? `<img src="${escapeHtml(photoUrl(photos[0]))}" alt="${escapeHtml(item.title)}">` : '<div class="flow-card-placeholder">Kein Bild</div>';
const confidence = Math.max(0, Math.min(100, item.completeness || 0));
const low = Math.max(0, (Number(item.price) || 0) * .92);
const high = Math.max(low, (Number(item.price) || 0) * 1.08);
content.innerHTML = `<div class="inspector-title-row"><div><h3>${escapeHtml(item.title)}</h3><div class="inspector-sku">SKU&nbsp;&nbsp;${escapeHtml(item.sku || '')}</div></div><span class="flow-state success">${item.status === 'sold' ? 'Verkauft' : 'Bereit'}</span></div><div class="inspector-hero">${hero}<span class="inspector-photo-count">▧ ${photos.length}</span></div>${photos.length > 1 ? `<div class="inspector-thumbs">${photos.slice(0, 5).map(photo => `<img src="${escapeHtml(photoUrl(photo))}" alt="">`).join('')}${photos.length > 5 ? `<span>+${photos.length - 5}</span>` : ''}</div>` : ''}<div class="inspector-price-row"><span>Preis</span><strong>${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}</strong></div><section class="inspector-card"><h4>AI-Konfidenz</h4><div class="confidence-row"><div class="confidence-ring" style="--confidence:${confidence}%"><span>${confidence}%</span></div><div class="confidence-copy"><strong>${confidence >= 85 ? 'Sehr hoch' : confidence >= 60 ? 'Prüfung empfohlen' : 'Unvollständig'}</strong><span>${confidence >= 85 ? 'Alle wichtigen Attribute wurden erkannt.' : 'Einige Angaben sollten ergänzt werden.'}</span></div></div></section><section class="inspector-card"><h4>Wichtige Attribute</h4><dl class="attribute-list"><dt>Marke</dt><dd>${escapeHtml(item.brand || '—')}</dd><dt>Kategorie</dt><dd>${escapeHtml(item.category || '—')}</dd><dt>Farbe</dt><dd>${escapeHtml(item.color || '—')}</dd><dt>Größe</dt><dd>${escapeHtml(item.size || '—')}</dd><dt>Zustand</dt><dd>${escapeHtml(item.condition || '—')}</dd><dt>Lagerort</dt><dd>${escapeHtml(item.storageLocation || 'Nicht zugewiesen')}</dd></dl></section>${item.price ? `<section class="inspector-card"><h4>Preisempfehlung</h4><div class="price-range">${escapeHtml(formatCurrency(low))} ${escapeHtml(formatCurrency(high))}</div><div class="recommended-price"><span>Empfohlener Preis</span><strong>${escapeHtml(formatCurrency(item.price))}</strong></div></section>` : ''}<div class="inspector-actions"><button class="inspector-action primary" type="button" data-inspector-action="review">Prüfen</button><button class="inspector-action" type="button" data-inspector-action="publish">Veröffentlichen</button><button class="inspector-action" type="button" data-inspector-action="inventory">Lagerort setzen</button></div>`;
content.querySelector('[data-inspector-action="review"]')?.addEventListener('click', () => showDetail(item.id));
content.querySelector('[data-inspector-action="publish"]')?.addEventListener('click', () => { publishState.selectedId = item.id; switchToTab('publish'); });
content.querySelector('[data-inspector-action="inventory"]')?.addEventListener('click', () => switchToTab('inventory'));
}
async function loadToday() {
const ready = document.getElementById('today-ready-grid');
if (ready) ready.innerHTML = '<div class="empty-state">Studio Flow wird geladen…</div>';
try {
const data = await api('GET', '/api/dashboard/workflow?limit=12');
studioWorkflowData = data;
renderTodaySummary(data);
renderTodayReady(data);
renderTodayScheduled(data);
renderStudioAttention(data);
renderStudioActivity(data);
const first = data.stages?.ready?.items?.[0] || data.stages?.scheduled?.items?.[0] || data.stages?.published?.items?.[0] || data.stages?.sold?.items?.[0];
if (first && !selectedStudioListingId) selectStudioListing(first.id);
else if (selectedStudioListingId) { const selected = getStudioItem(selectedStudioListingId); if (selected) renderStudioInspector(selected); }
} catch (error) {
if (ready) ready.innerHTML = `<div class="empty-state">Studio Flow konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
// --- Advanced Image Editor ---
function createPhotoEditorOperations() {
return {
rotation: 0,
straighten: 0,
flip_h: false,
flip_v: false,
crop: null,
exposure: 0,
brightness: 0,
contrast: 0,
highlights: 0,
shadows: 0,
temperature: 0,
saturation: 0,
sharpness: 0,
remove_background: false,
background_sensitivity: 45,
watermark: { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 },
resize: { width: 0, height: 0 },
format: 'jpeg',
quality: 90,
};
}
let photoEditorState = {
context: 'generator', platform: '', index: -1,
currentPath: '', rootPath: '', sourceImage: null, rootImage: null,
operations: createPhotoEditorOperations(), history: [], historyIndex: -1,
versions: [], compare: false, cropping: false, cropRect: null,
cropStart: null, cropEnd: null, _dragOrigin: null, _dragging: false,
};
function cloneEditorOperations(value = photoEditorState.operations) {
return JSON.parse(JSON.stringify(value));
}
function getPhotoEditorPhotos() {
if (photoEditorState.context === 'flux') return state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : [];
if (photoEditorState.context === 'media') return state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : [];
return photoEditorState.context === 'detail' ? (detailEditorState?.photos || []) : state.photos;
}
async function commitPhotoEditorResult(filename) {
if (!filename) return;
if (photoEditorState.context === 'flux') {
const current = state.fluxStudioCurrent || {};
const item = await api('POST', '/api/flux-studio/images', {
image_path: filename,
prompt: current.prompt || 'Nachbearbeitete FLUX-Ausgabe',
width: current.width,
height: current.height,
seed: current.seed,
steps: current.steps,
style: current.style,
final_prompt: current.final_prompt,
profile: current.profile,
duration_ms: current.duration_ms,
edited_from: current.id || null,
favorite: current.favorite,
job_id: current.job_id,
variant_index: current.variant_index,
prompt_mode: current.prompt_mode,
parameters: current.parameters,
});
renderFluxStudioPreview(item);
await loadFluxStudioHistory();
return;
}
if (photoEditorState.context === 'media') {
state.mediaCurrent = { ...(state.mediaCurrent || {}), image_path: filename, public_url: photoUrl(filename), source: 'edited', sources: ['edited'] };
await loadMediaLibrary();
return;
}
const photos = getPhotoEditorPhotos();
if (!photos[photoEditorState.index]) return;
photos[photoEditorState.index] = filename;
if (photoEditorState.context === 'detail') renderDetailPhotos();
else renderPhotoPreview();
}
function getPhotoEditorPlatform() {
if (photoEditorState.context === 'flux') return 'ai-generated';
if (photoEditorState.context === 'media') return state.mediaCurrent?.source || 'media-library';
if (photoEditorState.context === 'detail') {
return document.getElementById('detail-platform')?.value || detailEditorState?.listing?.platform || 'allgemein';
}
return state.platform || 'allgemein';
}
function setupPhotoEditor() {
document.getElementById('photo-editor-close').addEventListener('click', closePhotoEditor);
document.getElementById('pe-cancel').addEventListener('click', closePhotoEditor);
document.getElementById('photo-editor-overlay').addEventListener('click', event => {
if (event.target === event.currentTarget) closePhotoEditor();
});
document.getElementById('pe-undo').addEventListener('click', () => stepPhotoEditorHistory(-1));
document.getElementById('pe-redo').addEventListener('click', () => stepPhotoEditorHistory(1));
document.getElementById('pe-compare').addEventListener('click', togglePhotoEditorCompare);
document.getElementById('pe-restore-original').addEventListener('click', restorePhotoEditorOriginal);
const sliders = {
straighten: { digits: 1, suffix: '°' },
exposure: { digits: 1, suffix: '' },
brightness: { digits: 0, suffix: '' },
contrast: { digits: 0, suffix: '' },
highlights: { digits: 0, suffix: '' },
shadows: { digits: 0, suffix: '' },
temperature: { digits: 0, suffix: '' },
saturation: { digits: 0, suffix: '' },
sharpness: { digits: 0, suffix: '' },
};
for (const [property, format] of Object.entries(sliders)) {
const slider = document.getElementById(`pe-${property}`);
slider.addEventListener('input', () => {
photoEditorState.operations[property] = Number(slider.value);
updatePhotoEditorRangeValue(property, format.digits, format.suffix);
renderPhotoEditorCanvas();
});
slider.addEventListener('change', pushPhotoEditorHistory);
}
document.getElementById('pe-rotate-left').addEventListener('click', () => mutatePhotoEditor(() => {
photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 270) % 360;
}));
document.getElementById('pe-rotate-right').addEventListener('click', () => mutatePhotoEditor(() => {
photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 90) % 360;
}));
document.getElementById('pe-flip-h').addEventListener('click', () => mutatePhotoEditor(() => {
photoEditorState.operations.flip_h = !photoEditorState.operations.flip_h;
}));
document.getElementById('pe-flip-v').addEventListener('click', () => mutatePhotoEditor(() => {
photoEditorState.operations.flip_v = !photoEditorState.operations.flip_v;
}));
document.getElementById('pe-reset-adjustments').addEventListener('click', () => mutatePhotoEditor(() => {
for (const property of ['exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) {
photoEditorState.operations[property] = 0;
}
}));
document.getElementById('pe-crop-toggle').addEventListener('click', toggleCropMode);
document.getElementById('pe-crop-apply').addEventListener('click', applyCropSelection);
document.getElementById('pe-crop-cancel').addEventListener('click', cancelCropMode);
document.getElementById('pe-crop-ratio').addEventListener('change', setDefaultCropSelection);
document.getElementById('pe-remove-background').addEventListener('change', event => mutatePhotoEditor(() => {
photoEditorState.operations.remove_background = event.target.checked;
if (event.target.checked && photoEditorState.operations.format === 'jpeg') photoEditorState.operations.format = 'png';
}));
document.getElementById('pe-background-sensitivity').addEventListener('input', event => {
photoEditorState.operations.background_sensitivity = Number(event.target.value);
document.getElementById('pe-background-sensitivity-val').textContent = `${event.target.value}%`;
renderPhotoEditorCanvas();
});
document.getElementById('pe-background-sensitivity').addEventListener('change', pushPhotoEditorHistory);
document.getElementById('pe-watermark-enabled').addEventListener('change', event => mutatePhotoEditor(() => {
photoEditorState.operations.watermark.enabled = event.target.checked;
}));
document.getElementById('pe-watermark-text').addEventListener('input', event => {
photoEditorState.operations.watermark.text = event.target.value.slice(0, 80);
renderPhotoEditorCanvas();
});
document.getElementById('pe-watermark-text').addEventListener('change', pushPhotoEditorHistory);
document.getElementById('pe-watermark-position').addEventListener('change', event => mutatePhotoEditor(() => {
photoEditorState.operations.watermark.position = event.target.value;
}));
document.getElementById('pe-watermark-opacity').addEventListener('input', event => {
photoEditorState.operations.watermark.opacity = Number(event.target.value);
document.getElementById('pe-watermark-opacity-val').textContent = `${event.target.value}%`;
renderPhotoEditorCanvas();
});
document.getElementById('pe-watermark-opacity').addEventListener('change', pushPhotoEditorHistory);
const cropOverlay = document.getElementById('photo-editor-crop-overlay');
cropOverlay.addEventListener('mousedown', cropMouseDown);
cropOverlay.addEventListener('mousemove', cropMouseMove);
cropOverlay.addEventListener('mouseup', cropMouseUp);
cropOverlay.addEventListener('mouseleave', cropMouseUp);
cropOverlay.addEventListener('touchstart', event => { event.preventDefault(); cropMouseDown(touchToMouse(event)); }, { passive: false });
cropOverlay.addEventListener('touchmove', event => { event.preventDefault(); cropMouseMove(touchToMouse(event)); }, { passive: false });
cropOverlay.addEventListener('touchend', cropMouseUp);
document.getElementById('pe-format').addEventListener('change', event => mutatePhotoEditor(() => {
photoEditorState.operations.format = event.target.value;
}, false));
document.getElementById('pe-quality').addEventListener('input', event => {
photoEditorState.operations.quality = Number(event.target.value);
document.getElementById('pe-quality-val').textContent = `${event.target.value}%`;
});
document.getElementById('pe-quality').addEventListener('change', pushPhotoEditorHistory);
document.getElementById('pe-target-size').addEventListener('change', updatePhotoEditorResize);
document.getElementById('pe-target-width').addEventListener('change', updatePhotoEditorResize);
document.getElementById('pe-target-height').addEventListener('change', updatePhotoEditorResize);
document.getElementById('pe-apply').addEventListener('click', applyPhotoEdit);
window.addEventListener('resize', () => {
if (!document.getElementById('photo-editor-overlay').classList.contains('hidden')) syncCropOverlay();
});
document.addEventListener('keydown', event => {
const overlay = document.getElementById('photo-editor-overlay');
if (!overlay || overlay.classList.contains('hidden')) return;
if (event.key === 'Escape') {
event.preventDefault();
closePhotoEditor();
return;
}
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
event.preventDefault();
stepPhotoEditorHistory(event.shiftKey ? 1 : -1);
});
}
function updatePhotoEditorRangeValue(property, digits = 0, suffix = '') {
const value = Number(photoEditorState.operations[property] || 0);
const text = digits ? value.toFixed(digits).replace('.', ',') : String(Math.round(value));
document.getElementById(`pe-${property}-val`).textContent = `${text}${suffix}`;
}
function updatePhotoEditorControls() {
for (const property of ['straighten', 'exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) {
const input = document.getElementById(`pe-${property}`);
input.value = photoEditorState.operations[property];
}
updatePhotoEditorRangeValue('straighten', 1, '°');
updatePhotoEditorRangeValue('exposure', 1);
for (const property of ['brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) updatePhotoEditorRangeValue(property);
document.getElementById('pe-format').value = photoEditorState.operations.format;
document.getElementById('pe-quality').value = photoEditorState.operations.quality;
document.getElementById('pe-quality-val').textContent = `${photoEditorState.operations.quality}%`;
document.getElementById('pe-remove-background').checked = Boolean(photoEditorState.operations.remove_background);
document.getElementById('pe-background-sensitivity').value = Number(photoEditorState.operations.background_sensitivity ?? 45);
document.getElementById('pe-background-sensitivity-val').textContent = `${Number(photoEditorState.operations.background_sensitivity ?? 45)}%`;
const watermark = photoEditorState.operations.watermark || { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 };
document.getElementById('pe-watermark-enabled').checked = Boolean(watermark.enabled);
document.getElementById('pe-watermark-text').value = watermark.text || 'Vendoo';
document.getElementById('pe-watermark-position').value = watermark.position || 'bottom-right';
document.getElementById('pe-watermark-opacity').value = Number(watermark.opacity || 40);
document.getElementById('pe-watermark-opacity-val').textContent = `${Number(watermark.opacity || 40)}%`;
const { width, height } = photoEditorState.operations.resize || {};
const target = width && height && width === height && [1200, 1600, 2048].includes(width) ? String(width) : width || height ? 'custom' : 'original';
document.getElementById('pe-target-size').value = target;
document.getElementById('pe-custom-size').classList.toggle('hidden', target !== 'custom');
document.getElementById('pe-target-width').value = target === 'custom' && width ? width : '';
document.getElementById('pe-target-height').value = target === 'custom' && height ? height : '';
}
function mutatePhotoEditor(mutator, render = true) {
mutator();
updatePhotoEditorControls();
pushPhotoEditorHistory();
if (render) renderPhotoEditorCanvas();
}
function pushPhotoEditorHistory() {
const snapshot = cloneEditorOperations();
const current = photoEditorState.history[photoEditorState.historyIndex];
if (current && JSON.stringify(current) === JSON.stringify(snapshot)) return;
photoEditorState.history = photoEditorState.history.slice(0, photoEditorState.historyIndex + 1);
photoEditorState.history.push(snapshot);
photoEditorState.historyIndex = photoEditorState.history.length - 1;
updatePhotoEditorHistoryControls();
}
function stepPhotoEditorHistory(direction) {
const next = photoEditorState.historyIndex + direction;
if (next < 0 || next >= photoEditorState.history.length) return;
photoEditorState.historyIndex = next;
photoEditorState.operations = cloneEditorOperations(photoEditorState.history[next]);
cancelCropMode(false);
updatePhotoEditorControls();
updatePhotoEditorHistoryControls();
renderPhotoEditorCanvas();
}
function updatePhotoEditorHistoryControls() {
document.getElementById('pe-undo').disabled = photoEditorState.historyIndex <= 0;
document.getElementById('pe-redo').disabled = photoEditorState.historyIndex >= photoEditorState.history.length - 1;
document.getElementById('pe-history-status').textContent = `Schritt ${Math.max(1, photoEditorState.historyIndex + 1)} von ${Math.max(1, photoEditorState.history.length)}`;
}
function updatePhotoEditorResize() {
const target = document.getElementById('pe-target-size').value;
const custom = document.getElementById('pe-custom-size');
custom.classList.toggle('hidden', target !== 'custom');
if (target === 'original') photoEditorState.operations.resize = { width: 0, height: 0 };
else if (target === 'custom') {
photoEditorState.operations.resize = {
width: Math.max(0, Number(document.getElementById('pe-target-width').value) || 0),
height: Math.max(0, Number(document.getElementById('pe-target-height').value) || 0),
};
} else {
const size = Number(target);
photoEditorState.operations.resize = { width: size, height: size };
}
pushPhotoEditorHistory();
renderPhotoEditorCanvas();
}
function loadEditorImage(path) {
return new Promise((resolve, reject) => {
const image = new Image();
image.crossOrigin = 'anonymous';
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Bildvorschau konnte nicht geladen werden.'));
image.src = `${photoUrl(path)}${String(photoUrl(path)).includes('?') ? '&' : '?'}editor=${Date.now()}`;
});
}
async function openPhotoEditor(index, context = 'generator') {
const photos = context === 'flux' ? (state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : []) : context === 'media' ? (state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : []) : context === 'detail' ? (detailEditorState?.photos || []) : state.photos;
const photo = photos[index];
if (!photo) return;
photoEditorState = {
context,
platform: context === 'flux' ? 'ai-generated' : context === 'media' ? (state.mediaCurrent?.source || 'media-library') : context === 'detail' ? (detailEditorState?.listing?.platform || '') : state.platform,
index,
currentPath: photo,
rootPath: photo,
sourceImage: null,
rootImage: null,
operations: createPhotoEditorOperations(),
history: [], historyIndex: -1, versions: [], compare: false,
cropping: false, cropRect: null, cropStart: null, cropEnd: null,
_dragOrigin: null, _dragging: false,
};
const overlay = document.getElementById('photo-editor-overlay');
overlay.classList.remove('hidden');
overlay.setAttribute('aria-hidden', 'false');
document.getElementById('pe-image-meta').textContent = 'Versionen werden geladen …';
document.getElementById('pe-version-list').innerHTML = '<div class="empty-state">Versionen werden geladen …</div>';
updatePhotoEditorControls();
updatePhotoEditorHistoryControls();
setPhotoEditorCompare(false);
try {
const history = await api('GET', `/api/image-editor/versions?path=${encodeURIComponent(photo)}`);
photoEditorState.rootPath = history.root_path || photo;
photoEditorState.versions = [history.original, ...(history.versions || [])].filter(Boolean);
const [sourceImage, rootImage] = await Promise.all([
loadEditorImage(photo),
history.root_path && history.root_path !== photo ? loadEditorImage(history.root_path) : loadEditorImage(photo),
]);
photoEditorState.sourceImage = sourceImage;
photoEditorState.rootImage = rootImage;
resetPhotoEditorSession();
renderPhotoEditorVersions();
renderPhotoEditorCanvas();
} catch (error) {
toast(error.message || 'Bildeditor konnte nicht geöffnet werden.');
closePhotoEditor();
}
}
function resetPhotoEditorSession() {
const extension = String(photoEditorState.currentPath || '').split('.').pop()?.toLowerCase();
photoEditorState.operations = createPhotoEditorOperations();
if (extension === 'png') photoEditorState.operations.format = 'png';
else if (extension === 'webp') photoEditorState.operations.format = 'webp';
photoEditorState.history = [cloneEditorOperations()];
photoEditorState.historyIndex = 0;
cancelCropMode(false);
updatePhotoEditorControls();
updatePhotoEditorHistoryControls();
}
async function loadPhotoEditorVersion(path) {
if (!path || path === photoEditorState.currentPath) return;
try {
document.getElementById('pe-image-meta').textContent = 'Version wird geladen …';
photoEditorState.sourceImage = await loadEditorImage(path);
photoEditorState.currentPath = path;
resetPhotoEditorSession();
renderPhotoEditorVersions();
renderPhotoEditorCanvas();
} catch (error) { toast(error.message); }
}
function formatEditorFileSize(bytes) {
const size = Number(bytes) || 0;
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`;
return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
}
function renderPhotoEditorVersions() {
const list = document.getElementById('pe-version-list');
const versions = photoEditorState.versions || [];
document.getElementById('pe-version-count').textContent = String(Math.max(0, versions.length - 1));
if (!versions.length) {
list.innerHTML = '<div class="empty-state">Noch keine Bearbeitungsversion.</div>';
return;
}
list.innerHTML = versions.map((version, index) => {
const path = version.output_path || version.root_path;
const isOriginal = version.id === 'original';
const date = version.created_at ? new Date(version.created_at).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
const dimensions = version.width && version.height ? `${version.width} × ${version.height}` : '';
return `<button class="pe-version-item${path === photoEditorState.currentPath ? ' active' : ''}" type="button" data-version-path="${escapeHtml(path)}"><span>${isOriginal ? 'ORIG' : `V${versions.length - index}`}</span><div><strong>${isOriginal ? 'Unverändertes Original' : 'Bearbeitungsversion'}</strong><small>${escapeHtml([dimensions, formatEditorFileSize(version.file_size), date].filter(Boolean).join(' · '))}</small></div></button>`;
}).join('');
list.querySelectorAll('[data-version-path]').forEach(button => button.addEventListener('click', () => loadPhotoEditorVersion(button.dataset.versionPath)));
}
function createPreviewCanvas(image, operations, { applyCrop = true, maxDimension = 1100 } = {}) {
const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height));
const sourceWidth = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
const sourceHeight = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const angle = (operations.rotation + operations.straighten) * Math.PI / 180;
const cos = Math.abs(Math.cos(angle));
const sin = Math.abs(Math.sin(angle));
const outputWidth = Math.max(1, Math.ceil(sourceWidth * cos + sourceHeight * sin));
const outputHeight = Math.max(1, Math.ceil(sourceWidth * sin + sourceHeight * cos));
const geometry = document.createElement('canvas');
geometry.width = outputWidth;
geometry.height = outputHeight;
const geometryContext = geometry.getContext('2d', { willReadFrequently: true });
geometryContext.fillStyle = '#ffffff';
geometryContext.fillRect(0, 0, outputWidth, outputHeight);
geometryContext.save();
geometryContext.translate(outputWidth / 2, outputHeight / 2);
geometryContext.rotate(angle);
geometryContext.scale(operations.flip_h ? -1 : 1, operations.flip_v ? -1 : 1);
geometryContext.drawImage(image, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight);
geometryContext.restore();
let result = geometry;
if (applyCrop && operations.crop) {
const left = Math.max(0, Math.min(geometry.width - 1, Math.round(operations.crop.x * geometry.width)));
const top = Math.max(0, Math.min(geometry.height - 1, Math.round(operations.crop.y * geometry.height)));
const width = Math.max(1, Math.min(geometry.width - left, Math.round(operations.crop.width * geometry.width)));
const height = Math.max(1, Math.min(geometry.height - top, Math.round(operations.crop.height * geometry.height)));
result = document.createElement('canvas');
result.width = width;
result.height = height;
result.getContext('2d', { willReadFrequently: true }).drawImage(geometry, left, top, width, height, 0, 0, width, height);
}
applyPreviewToneAdjustments(result, operations);
applyPreviewBackgroundRemoval(result, operations);
applyPreviewWatermark(result, operations);
return result;
}
function applyPreviewBackgroundRemoval(canvas, operations) {
if (!operations.remove_background) return;
const context = canvas.getContext('2d', { willReadFrequently: true });
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const cornerIndexes = [0, (canvas.width - 1) * 4, ((canvas.height - 1) * canvas.width) * 4, (canvas.width * canvas.height - 1) * 4];
const background = cornerIndexes.reduce((sum, index) => ({ r: sum.r + data[index], g: sum.g + data[index + 1], b: sum.b + data[index + 2] }), { r: 0, g: 0, b: 0 });
background.r /= 4; background.g /= 4; background.b /= 4;
const level = Math.max(0, Math.min(100, Number(operations.background_sensitivity ?? 45)));
const solidThreshold = 24 + level * 2.1;
const featherWidth = 45 + level * 1.2;
const featherEnd = solidThreshold + featherWidth;
for (let index = 0; index < data.length; index += 4) {
const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b);
if (distance <= solidThreshold) data[index + 3] = 0;
else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth);
}
context.putImageData(imageData, 0, 0);
}
function applyPreviewWatermark(canvas, operations) {
const watermark = operations.watermark || {};
const text = String(watermark.text || '').trim();
if (!watermark.enabled || !text) return;
const context = canvas.getContext('2d');
const margin = Math.max(10, Math.round(Math.min(canvas.width, canvas.height) * 0.025));
const fontSize = Math.max(16, Math.round(canvas.width * 0.04));
const position = watermark.position || 'bottom-right';
let x = canvas.width - margin; let y = canvas.height - margin; let align = 'right'; let baseline = 'bottom';
if (position.includes('left')) { x = margin; align = 'left'; }
if (position.includes('top')) { y = margin; baseline = 'top'; }
if (position === 'center') { x = canvas.width / 2; y = canvas.height / 2; align = 'center'; baseline = 'middle'; }
context.save();
context.globalAlpha = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100));
context.font = `700 ${fontSize}px system-ui, sans-serif`;
context.textAlign = align;
context.textBaseline = baseline;
context.lineWidth = Math.max(2, fontSize * 0.08);
context.strokeStyle = 'rgba(0,0,0,.42)';
context.fillStyle = '#ffffff';
context.strokeText(text, x, y);
context.fillText(text, x, y);
context.restore();
}
function applyPreviewToneAdjustments(canvas, operations) {
const context = canvas.getContext('2d', { willReadFrequently: true });
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const exposure = 2 ** operations.exposure;
const brightness = operations.brightness / 100 * 0.22;
const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100));
const contrast = (1 + contrastValue) / (1 - contrastValue);
const highlights = operations.highlights / 100;
const shadows = operations.shadows / 100;
const temperature = operations.temperature / 100;
const saturation = 1 + operations.saturation / 100;
for (let index = 0; index < data.length; index += 4) {
let red = data[index] / 255;
let green = data[index + 1] / 255;
let blue = data[index + 2] / 255;
red = (red * exposure + brightness - 0.5) * contrast + 0.5;
green = (green * exposure + brightness - 0.5) * contrast + 0.5;
blue = (blue * exposure + brightness - 0.5) * contrast + 0.5;
const lumBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722));
const shadowWeight = (1 - lumBefore) ** 2;
const highlightWeight = lumBefore ** 2;
const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28;
const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38;
red += shadowDelta + highlightDelta + temperature * 0.12;
green += shadowDelta + highlightDelta + temperature * 0.015;
blue += shadowDelta + highlightDelta - temperature * 0.12;
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722;
red = luminance + (red - luminance) * saturation;
green = luminance + (green - luminance) * saturation;
blue = luminance + (blue - luminance) * saturation;
data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255);
data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255);
data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255);
}
context.putImageData(imageData, 0, 0);
if (operations.sharpness > 0) applyPreviewSharpen(canvas, operations.sharpness / 100);
}
function applyPreviewSharpen(canvas, strength) {
if (canvas.width * canvas.height > 1_500_000) return;
const context = canvas.getContext('2d', { willReadFrequently: true });
const source = context.getImageData(0, 0, canvas.width, canvas.height);
const output = context.createImageData(canvas.width, canvas.height);
output.data.set(source.data);
const amount = Math.min(1, Math.max(0, strength)) * 0.75;
const width = canvas.width;
for (let y = 1; y < canvas.height - 1; y += 1) {
for (let x = 1; x < width - 1; x += 1) {
const index = (y * width + x) * 4;
for (let channel = 0; channel < 3; channel += 1) {
const center = source.data[index + channel];
const average = (source.data[index - 4 + channel] + source.data[index + 4 + channel] + source.data[index - width * 4 + channel] + source.data[index + width * 4 + channel]) / 4;
output.data[index + channel] = Math.max(0, Math.min(255, center + (center - average) * amount));
}
}
}
context.putImageData(output, 0, 0);
}
function calculatePhotoEditorOutputDimensions() {
const image = photoEditorState.sourceImage;
if (!image) return { width: 0, height: 0 };
const angle = (photoEditorState.operations.rotation + photoEditorState.operations.straighten) * Math.PI / 180;
const cos = Math.abs(Math.cos(angle));
const sin = Math.abs(Math.sin(angle));
let width = Math.ceil(image.naturalWidth * cos + image.naturalHeight * sin);
let height = Math.ceil(image.naturalWidth * sin + image.naturalHeight * cos);
if (photoEditorState.operations.crop) {
width = Math.max(1, Math.round(width * photoEditorState.operations.crop.width));
height = Math.max(1, Math.round(height * photoEditorState.operations.crop.height));
}
const target = photoEditorState.operations.resize || {};
if (target.width || target.height) {
const scale = Math.min(target.width ? target.width / width : Infinity, target.height ? target.height / height : Infinity, 1);
width = Math.max(1, Math.round(width * scale));
height = Math.max(1, Math.round(height * scale));
}
return { width, height };
}
function renderPhotoEditorCanvas() {
const image = photoEditorState.sourceImage;
if (!image) return;
const preview = createPreviewCanvas(image, photoEditorState.operations, { applyCrop: !photoEditorState.cropping });
const canvas = document.getElementById('photo-editor-canvas');
canvas.width = preview.width;
canvas.height = preview.height;
canvas.getContext('2d').drawImage(preview, 0, 0);
if (photoEditorState.rootImage) {
const before = createPreviewCanvas(photoEditorState.rootImage, createPhotoEditorOperations(), { maxDimension: 850 });
const beforeCanvas = document.getElementById('photo-editor-before-canvas');
beforeCanvas.width = before.width;
beforeCanvas.height = before.height;
beforeCanvas.getContext('2d').drawImage(before, 0, 0);
}
const dimensions = calculatePhotoEditorOutputDimensions();
document.getElementById('pe-output-dimensions').textContent = dimensions.width && dimensions.height ? `Ausgabe: ${dimensions.width} × ${dimensions.height} px` : 'Ausgabegröße wird ermittelt';
document.getElementById('pe-image-meta').textContent = `${photoEditorState.currentPath === photoEditorState.rootPath ? 'Original' : 'Version'} · ${image.naturalWidth} × ${image.naturalHeight} px · Original bleibt erhalten`;
requestAnimationFrame(syncCropOverlay);
}
function setPhotoEditorCompare(enabled) {
photoEditorState.compare = Boolean(enabled);
document.getElementById('pe-before-pane').classList.toggle('hidden', !photoEditorState.compare);
const button = document.getElementById('pe-compare');
button.setAttribute('aria-pressed', String(photoEditorState.compare));
button.classList.toggle('active', photoEditorState.compare);
}
function togglePhotoEditorCompare() {
setPhotoEditorCompare(!photoEditorState.compare);
requestAnimationFrame(syncCropOverlay);
}
function touchToMouse(event) {
const touch = event.touches?.[0] || event.changedTouches?.[0];
return { clientX: touch?.clientX || 0, clientY: touch?.clientY || 0, target: event.target };
}
function toggleCropMode() {
if (photoEditorState.cropping) return cancelCropMode();
photoEditorState.cropping = true;
document.getElementById('pe-crop-toggle').classList.add('active');
document.getElementById('pe-crop-toggle').textContent = 'Auswahl aktiv';
document.getElementById('pe-crop-apply').classList.remove('hidden');
document.getElementById('pe-crop-cancel').classList.remove('hidden');
document.getElementById('pe-crop-hint').textContent = 'Rahmen aufziehen oder verschieben; anschließend anwenden.';
renderPhotoEditorCanvas();
requestAnimationFrame(() => {
syncCropOverlay();
if (photoEditorState.operations.crop) {
const overlay = document.getElementById('photo-editor-crop-overlay');
photoEditorState.cropRect = {
x: photoEditorState.operations.crop.x * overlay.width,
y: photoEditorState.operations.crop.y * overlay.height,
w: photoEditorState.operations.crop.width * overlay.width,
h: photoEditorState.operations.crop.height * overlay.height,
};
drawCropOverlay();
} else setDefaultCropSelection();
});
}
function cancelCropMode(render = true) {
photoEditorState.cropping = false;
photoEditorState.cropRect = null;
photoEditorState.cropStart = null;
photoEditorState.cropEnd = null;
photoEditorState._dragOrigin = null;
photoEditorState._dragging = false;
const overlay = document.getElementById('photo-editor-crop-overlay');
overlay.classList.add('hidden');
clearCropOverlay();
const toggle = document.getElementById('pe-crop-toggle');
toggle.classList.remove('active');
toggle.textContent = '✂ Auswahl starten';
document.getElementById('pe-crop-apply').classList.add('hidden');
document.getElementById('pe-crop-cancel').classList.add('hidden');
document.getElementById('pe-crop-hint').textContent = photoEditorState.operations.crop ? 'Ein nicht destruktiver Zuschnitt ist aktiv.' : 'Zuschnitt ist nicht aktiv.';
if (render) renderPhotoEditorCanvas();
}
function syncCropOverlay() {
const overlay = document.getElementById('photo-editor-crop-overlay');
const canvas = document.getElementById('photo-editor-canvas');
const wrap = document.getElementById('photo-editor-canvas-wrap');
if (!photoEditorState.cropping || !canvas.offsetWidth || !canvas.offsetHeight) {
overlay.classList.add('hidden');
return;
}
const canvasRect = canvas.getBoundingClientRect();
const wrapRect = wrap.getBoundingClientRect();
overlay.width = Math.max(1, Math.round(canvasRect.width));
overlay.height = Math.max(1, Math.round(canvasRect.height));
overlay.style.width = `${canvasRect.width}px`;
overlay.style.height = `${canvasRect.height}px`;
overlay.style.left = `${canvasRect.left - wrapRect.left}px`;
overlay.style.top = `${canvasRect.top - wrapRect.top}px`;
overlay.classList.remove('hidden');
drawCropOverlay();
}
function getCropRatio() {
const value = document.getElementById('pe-crop-ratio').value;
if (value === 'free') return null;
if (value === 'original') {
const canvas = document.getElementById('photo-editor-canvas');
return canvas.width / Math.max(1, canvas.height);
}
const [width, height] = value.split(':').map(Number);
return width && height ? width / height : null;
}
function setDefaultCropSelection() {
if (!photoEditorState.cropping) return;
const overlay = document.getElementById('photo-editor-crop-overlay');
const ratio = getCropRatio();
const margin = Math.min(overlay.width, overlay.height) * 0.08;
let width = Math.max(20, overlay.width - margin * 2);
let height = Math.max(20, overlay.height - margin * 2);
if (ratio) {
if (width / height > ratio) width = height * ratio;
else height = width / ratio;
}
photoEditorState.cropRect = { x: (overlay.width - width) / 2, y: (overlay.height - height) / 2, w: width, h: height };
drawCropOverlay();
}
function getCropCanvasCoords(event) {
const overlay = document.getElementById('photo-editor-crop-overlay');
const rect = overlay.getBoundingClientRect();
return {
x: Math.max(0, Math.min(overlay.width, event.clientX - rect.left)),
y: Math.max(0, Math.min(overlay.height, event.clientY - rect.top)),
};
}
function cropMouseDown(event) {
if (!photoEditorState.cropping) return;
photoEditorState._dragOrigin = getCropCanvasCoords(event);
photoEditorState._dragging = true;
photoEditorState.cropStart = photoEditorState._dragOrigin;
photoEditorState.cropEnd = photoEditorState._dragOrigin;
}
function cropMouseMove(event) {
if (!photoEditorState.cropping || !photoEditorState._dragging || !photoEditorState._dragOrigin) return;
const overlay = document.getElementById('photo-editor-crop-overlay');
const origin = photoEditorState._dragOrigin;
let point = getCropCanvasCoords(event);
const ratio = getCropRatio();
if (ratio) {
const directionX = point.x >= origin.x ? 1 : -1;
const directionY = point.y >= origin.y ? 1 : -1;
let width = Math.abs(point.x - origin.x);
let height = Math.abs(point.y - origin.y);
if (width / Math.max(1, height) > ratio) height = width / ratio;
else width = height * ratio;
width = Math.min(width, directionX > 0 ? overlay.width - origin.x : origin.x);
height = Math.min(height, directionY > 0 ? overlay.height - origin.y : origin.y);
if (width / Math.max(1, height) > ratio) width = height * ratio;
else height = width / ratio;
point = { x: origin.x + width * directionX, y: origin.y + height * directionY };
}
photoEditorState.cropEnd = point;
const x = Math.min(origin.x, point.x);
const y = Math.min(origin.y, point.y);
photoEditorState.cropRect = { x, y, w: Math.abs(point.x - origin.x), h: Math.abs(point.y - origin.y) };
drawCropOverlay();
}
function cropMouseUp() {
if (!photoEditorState.cropping) return;
photoEditorState._dragging = false;
photoEditorState._dragOrigin = null;
}
function drawCropOverlay() {
const overlay = document.getElementById('photo-editor-crop-overlay');
const context = overlay.getContext('2d');
context.clearRect(0, 0, overlay.width, overlay.height);
if (!photoEditorState.cropping) return;
context.fillStyle = 'rgba(7, 11, 17, 0.58)';
context.fillRect(0, 0, overlay.width, overlay.height);
const rect = photoEditorState.cropRect;
if (!rect || rect.w < 2 || rect.h < 2) return;
context.clearRect(rect.x, rect.y, rect.w, rect.h);
context.strokeStyle = '#ffffff';
context.lineWidth = 2;
context.setLineDash([]);
context.strokeRect(rect.x, rect.y, rect.w, rect.h);
context.strokeStyle = 'rgba(255,255,255,.42)';
context.lineWidth = 1;
for (let index = 1; index < 3; index += 1) {
context.beginPath(); context.moveTo(rect.x + rect.w * index / 3, rect.y); context.lineTo(rect.x + rect.w * index / 3, rect.y + rect.h); context.stroke();
context.beginPath(); context.moveTo(rect.x, rect.y + rect.h * index / 3); context.lineTo(rect.x + rect.w, rect.y + rect.h * index / 3); context.stroke();
}
const canvas = document.getElementById('photo-editor-canvas');
const realWidth = Math.round(rect.w * canvas.width / overlay.width);
const realHeight = Math.round(rect.h * canvas.height / overlay.height);
const label = `${realWidth} × ${realHeight}`;
context.font = '12px ui-monospace, monospace';
const textWidth = context.measureText(label).width;
context.fillStyle = 'rgba(0,0,0,.74)';
context.fillRect(rect.x + rect.w / 2 - textWidth / 2 - 7, Math.max(rect.y + 5, rect.y + rect.h - 25), textWidth + 14, 20);
context.fillStyle = '#ffffff';
context.textAlign = 'center';
context.fillText(label, rect.x + rect.w / 2, Math.max(rect.y + 19, rect.y + rect.h - 11));
}
function clearCropOverlay() {
const overlay = document.getElementById('photo-editor-crop-overlay');
overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height);
}
function applyCropSelection() {
const rect = photoEditorState.cropRect;
const overlay = document.getElementById('photo-editor-crop-overlay');
if (!rect || rect.w < 10 || rect.h < 10) return toast('Bitte zuerst einen gültigen Zuschnitt wählen.');
photoEditorState.operations.crop = {
x: Math.max(0, Math.min(1, rect.x / overlay.width)),
y: Math.max(0, Math.min(1, rect.y / overlay.height)),
width: Math.max(0.01, Math.min(1, rect.w / overlay.width)),
height: Math.max(0.01, Math.min(1, rect.h / overlay.height)),
};
cancelCropMode(false);
pushPhotoEditorHistory();
renderPhotoEditorCanvas();
}
async function restorePhotoEditorOriginal() {
if (!photoEditorState.rootPath) return;
try {
await commitPhotoEditorResult(photoEditorState.rootPath);
toast('Unverändertes Original wiederhergestellt.');
closePhotoEditor();
} catch (error) { toast(error.message || 'Original konnte nicht wiederhergestellt werden.'); }
}
async function applyPhotoEdit() {
const button = document.getElementById('pe-apply');
const originalLabel = button.textContent;
button.disabled = true;
button.textContent = 'Neue Version wird gerendert …';
try {
const result = await api('POST', '/api/image-editor/render', {
source_path: photoEditorState.currentPath,
context: photoEditorState.context,
platform: getPhotoEditorPlatform(),
operations: photoEditorState.operations,
});
await commitPhotoEditorResult(result.filename);
toast(`Neue Bildversion gespeichert (${result.metadata?.width || '?'} × ${result.metadata?.height || '?'} px).`);
closePhotoEditor();
} catch (error) {
toast(error.message || 'Bildversion konnte nicht gespeichert werden.');
} finally {
button.disabled = false;
button.textContent = originalLabel;
}
}
function closePhotoEditor() {
const overlay = document.getElementById('photo-editor-overlay');
overlay.classList.add('hidden');
overlay.setAttribute('aria-hidden', 'true');
cancelCropMode(false);
photoEditorState.sourceImage = null;
photoEditorState.rootImage = null;
photoEditorState.versions = [];
}
// --- Photo Drag & Drop Sorting ---
function enablePhotoDragSort() {
const container = document.getElementById('photo-preview');
let dragIdx = -1;
container.addEventListener('dragstart', e => {
const thumb = e.target.closest('.thumb');
if (!thumb) return;
dragIdx = [...container.children].indexOf(thumb);
thumb.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
const thumb = e.target.closest('.thumb');
if (thumb) {
container.querySelectorAll('.thumb').forEach(t => t.classList.remove('drag-over'));
thumb.classList.add('drag-over');
}
});
container.addEventListener('drop', e => {
e.preventDefault();
container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); });
const thumb = e.target.closest('.thumb');
if (!thumb) return;
const dropIdx = [...container.children].indexOf(thumb);
if (dragIdx >= 0 && dropIdx >= 0 && dragIdx !== dropIdx) {
const moved = state.photos.splice(dragIdx, 1)[0];
state.photos.splice(dropIdx, 0, moved);
renderPhotoPreview();
}
dragIdx = -1;
});
container.addEventListener('dragend', () => {
container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); });
dragIdx = -1;
});
}
// --- Price Suggestion ---
async function fetchPriceSuggestion() {
const title = document.getElementById('result-title')?.value;
const category = document.getElementById('meta-category-picker')?.textContent;
if (!title) return;
try {
const data = await api('GET', `/api/price-suggest?title=${encodeURIComponent(title)}&category=${encodeURIComponent(category || '')}`);
if (data.suggestion) {
const priceInput = document.getElementById('meta-price');
const currentPrice = parseFloat(priceInput.value);
const hint = document.getElementById('price-suggestion-hint');
const html = `Preisvorschlag: <strong>${data.suggestion}€</strong> (${data.min}${data.max}€, ${data.count} ähnliche)`;
if (hint) {
hint.innerHTML = html;
hint.classList.remove('hidden');
} else {
const el = document.createElement('div');
el.id = 'price-suggestion-hint';
el.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:4px;cursor:pointer';
el.innerHTML = html;
el.addEventListener('click', () => { priceInput.value = data.suggestion; });
priceInput.parentElement.appendChild(el);
}
}
} catch {}
}
// --- Backup & Restore (now handled in setupAdmin for system tab) ---
function setupBackup() {
// Legacy backup buttons in settings - no longer exist, backup is in admin > system tab
}
// --- Browser Notifications ---
function setupNotifications() {
if (!('Notification' in window)) return;
if (Notification.permission === 'default') {
Notification.requestPermission();
}
}
function notify(title, body) {
if (!('Notification' in window) || Notification.permission !== 'granted') return;
new Notification(title, { body, icon: '/favicon.ico' });
}
// --- Permissions & collaborative editing ---
function userCan(permission) {
return Boolean(currentUser?.role === 'admin' || currentUser?.permissions?.includes(permission));
}
function applyPermissionUi() {
const rules = [
['generator', 'listings.edit'], ['image-factory', 'batch.execute'], ['flux-studio', 'generation.execute'],
['publish', 'publishing.view'], ['quality-center', 'listings.view'], ['inventory', 'listings.view'],
];
for (const [tab, permission] of rules) {
const button = document.querySelector(`.nav-item[data-tab="${tab}"]`);
if (button) button.classList.toggle('hidden', !userCan(permission));
}
document.querySelectorAll('[data-requires-permission]').forEach(element => {
const permission = element.dataset.requiresPermission;
element.classList.toggle('hidden', !userCan(permission));
if ('disabled' in element) element.disabled = !userCan(permission);
});
document.body.dataset.userRole = currentUser?.role || 'viewer';
}
function clearListingLockTimer() {
if (listingEditLock.timer) window.clearInterval(listingEditLock.timer);
listingEditLock.timer = null;
}
async function releaseListingLock({ quiet = true, keepalive = false } = {}) {
clearListingLockTimer();
if (!listingEditLock.listingId || !listingEditLock.token) {
listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null;
return;
}
const id = listingEditLock.listingId;
const token = listingEditLock.token;
listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null;
try {
if (keepalive) {
await fetch(`/api/locks/listing/${id}`, {
method: 'DELETE', credentials: 'same-origin', keepalive: true,
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
body: JSON.stringify({ token }),
});
} else await api('DELETE', `/api/locks/listing/${id}`, { token }, { retry: false, timeoutMs: 8000 });
} catch (error) { if (!quiet) toast(error.message || 'Bearbeitungssperre konnte nicht gelöst werden.'); }
}
function showListingLockBanner(lock, owned = false) {
document.getElementById('listing-lock-banner')?.remove();
const detail = document.querySelector('.listing-editor-shell');
if (!detail || !lock) return;
const banner = document.createElement('div');
banner.id = 'listing-lock-banner';
banner.className = `listing-lock-banner ${owned ? 'is-mine' : 'is-foreign'}`;
banner.innerHTML = owned
? `<span data-vd-icon="lock-keyhole"></span><div><strong>Bearbeitung für dich reserviert</strong><p>Andere Benutzer können diesen Artikel ansehen, aber nicht gleichzeitig speichern.</p></div>`
: `<span data-vd-icon="lock"></span><div><strong>${escapeHtml(lock.user_name || lock.user_email || 'Ein anderer Benutzer')} bearbeitet diesen Artikel</strong><p>Du kannst den Artikel ansehen. Speichern ist bis zum Ablauf der Sperre deaktiviert.</p></div>`;
detail.prepend(banner);
const save = document.getElementById('detail-save');
if (save && !owned) { save.disabled = true; save.title = 'Artikel ist durch einen anderen Benutzer gesperrt'; }
}
async function acquireListingLock(listingId) {
await releaseListingLock({ quiet: true });
if (!listingId || !userCan('listings.edit')) return { acquired: false, readOnly: true };
try {
const result = await api('POST', `/api/locks/listing/${listingId}/acquire`, { client_label: `${navigator.platform || 'Browser'} · Artikel-Editor`, ttl_seconds: 120 });
listingEditLock.listingId = Number(listingId);
listingEditLock.token = result.token;
listingEditLock.lock = result.lock;
showListingLockBanner(result.lock, true);
listingEditLock.timer = window.setInterval(async () => {
if (!listingEditLock.listingId || !listingEditLock.token) return;
try {
const renewed = await api('POST', `/api/locks/listing/${listingEditLock.listingId}/renew`, { token: listingEditLock.token, ttl_seconds: 120 }, { timeoutMs: 10000, retry: false });
listingEditLock.lock = renewed.lock;
} catch (error) {
clearListingLockTimer();
const save = document.getElementById('detail-save');
if (save) save.disabled = true;
toast(`Bearbeitungssperre verloren: ${error.message}`);
}
}, 45000);
return { acquired: true, lock: result.lock };
} catch (error) {
if (error.status === 409) {
showListingLockBanner(error.lock || { user_name: 'Ein anderer Benutzer' }, false);
return { acquired: false, readOnly: true, lock: error.lock };
}
toast(`Bearbeitungssperre konnte nicht erstellt werden: ${error.message}`);
return { acquired: false, readOnly: true };
}
}
window.addEventListener('beforeunload', () => { releaseListingLock({ quiet: true, keepalive: true }); });
// --- User Menu ---
let currentUser = null;
async function setupUserMenu() {
try {
currentUser = await api('GET', '/api/me');
if (!currentUser || currentUser.setupMode) {
document.getElementById('user-menu').classList.add('hidden');
return;
}
_csrfToken = currentUser.csrf || getCsrf();
// Populate sidebar user area
const initials = currentUser.initials || currentUser.name?.[0]?.toUpperCase() || '?';
const sidebarAvatar = document.getElementById('sidebar-avatar');
if (sidebarAvatar) {
sidebarAvatar.textContent = initials;
sidebarAvatar.style.background = currentUser.avatar_color || 'var(--accent)';
}
const sidebarName = document.getElementById('sidebar-name');
if (sidebarName) sidebarName.textContent = currentUser.name || currentUser.email || '';
const sidebarRole = document.getElementById('sidebar-role');
if (sidebarRole) sidebarRole.textContent = currentUser.role || '';
const topbarAvatar = document.getElementById('topbar-avatar');
if (topbarAvatar) {
topbarAvatar.textContent = initials;
topbarAvatar.style.background = currentUser.avatar_color || 'var(--vd-ink)';
topbarAvatar.title = currentUser.name || currentUser.email || '';
}
const adminNav = document.getElementById('nav-admin-main');
if (adminNav) adminNav.classList.toggle('hidden', !['admin', 'manager'].includes(currentUser.role));
if (currentUser.role === 'manager') {
document.querySelectorAll('.admin-tab:not([data-admin-tab="audit"])').forEach(tab => tab.classList.add('hidden'));
document.getElementById('invite-user-btn')?.classList.add('hidden');
adminNav?.setAttribute('data-admin-tab', 'audit');
}
applyPermissionUi();
// Logout
document.getElementById('sidebar-logout-btn')?.addEventListener('click', async () => {
await releaseListingLock({ quiet: true });
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken || getCsrf() } });
window.location.href = '/login.html';
});
} catch {
document.getElementById('sidebar-user')?.classList.add('hidden');
}
}
// --- Operations Center 1.30.0 ---
const operationsState = { status: null, backups: [], extensions: null, restoreFile: null, updateFile: null };
function operationFormatBytes(value) {
const bytes = Number(value || 0);
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
return `${(bytes / (1024 ** index)).toLocaleString('de-DE', { maximumFractionDigits: index ? 1 : 0 })} ${units[index]}`;
}
function operationFormatDate(value) {
if (!value) return '—';
try { return new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); }
catch { return String(value); }
}
async function operationsUploadRequest(url, field, file) {
const form = new FormData();
form.append(field, file);
const response = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' });
const data = await response.json().catch(() => ({}));
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
return data;
}
function renderOperationsStatus() {
const status = operationsState.status || {};
const pending = status.pending;
const banner = document.getElementById('operations-pending-banner');
if (banner) {
banner.classList.toggle('hidden', !pending);
if (pending) banner.innerHTML = `<span data-vd-icon="alert-triangle"></span><div><strong>${pending.type === 'update' ? `Update auf ${escapeHtml(pending.version_to || 'neue Version')}` : 'Wiederherstellung'} ist vorbereitet</strong><p>Vendoo beenden und <code>setup.bat</code> → <b>11 Vorbereitete Operation anwenden</b> ausführen.</p></div><button type="button" class="secondary-btn" id="operations-pending-cancel">Vorbereitung verwerfen</button>`;
}
document.getElementById('operations-pending-cancel')?.addEventListener('click', async () => {
if (!confirm('Vorbereitete Operation wirklich verwerfen?')) return;
try { await api('POST', '/api/operations/pending/cancel'); toast('Vorbereitung verworfen.'); await loadOperationsCenter(); }
catch (error) { toast(error.message || 'Vorbereitung konnte nicht verworfen werden.'); }
});
const version = document.getElementById('operations-version-card');
if (version) {
const last = status.last;
version.innerHTML = `<strong>Vendoo ${escapeHtml(status.version || VENDOO_UI_BUILD)}</strong><span>${last ? `Letzte Operation: ${escapeHtml(last.type || 'System')} · ${escapeHtml(last.status || 'unbekannt')}` : 'Noch keine Setup-Operation protokolliert.'}</span>`;
}
const migrations = document.getElementById('operations-migration-list');
if (migrations) {
const rows = status.migrations || [];
migrations.innerHTML = rows.length ? rows.slice(0, 8).map(row => `<div><span data-vd-icon="check-circle-2"></span><p><strong>${escapeHtml(row.app_version)}</strong><small>${escapeHtml(row.description || row.migration_key)} · ${operationFormatDate(row.applied_at)}</small></p></div>`).join('') : '<div class="empty-state">Keine Migrationsinformationen vorhanden.</div>';
}
const settings = status.settings || {};
const enabled = document.getElementById('operations-auto-enabled'); if (enabled) enabled.checked = !!settings.auto_backup_enabled;
const hour = document.getElementById('operations-auto-hour'); if (hour) hour.value = String(settings.auto_backup_hour ?? 3);
const retention = document.getElementById('operations-auto-retention'); if (retention) retention.value = String(settings.auto_backup_retention ?? 7);
const uploads = document.getElementById('operations-auto-uploads'); if (uploads) uploads.checked = settings.include_uploads_automatic !== false;
const updateUrl = document.getElementById('operations-update-url'); if (updateUrl && document.activeElement !== updateUrl) updateUrl.value = settings.update_manifest_url || '';
window.VendooIcons?.render(document.getElementById('admin-system-panel'));
}
function renderOperationsBackups() {
const list = document.getElementById('operations-backup-list');
const summary = document.getElementById('operations-backup-summary');
if (!list) return;
const backups = operationsState.backups || [];
const total = backups.reduce((sum, item) => sum + Number(item.file_size || 0), 0);
if (summary) summary.textContent = `${backups.length} Sicherung(en) · ${operationFormatBytes(total)}`;
if (!backups.length) {
list.innerHTML = '<div class="empty-state">Noch kein Backup vorhanden.</div>';
return;
}
list.innerHTML = backups.map(item => {
const verified = item.status === 'verified';
const includesUploads = item.options?.includeUploads;
return `<article class="operations-backup-row${item.exists ? '' : ' is-missing'}">
<div class="operation-backup-icon"><span data-vd-icon="${verified ? 'shield-check' : 'archive'}"></span></div>
<div class="operation-backup-main"><strong>${escapeHtml(item.label || item.file_name)}</strong><span>${escapeHtml(item.kind === 'automatic' ? 'Automatisch' : item.kind === 'database' ? 'Nur Datenbank' : 'Manuell')} · ${operationFormatDate(item.created_at)}</span><small>${operationFormatBytes(item.file_size)} · ${includesUploads ? 'mit Uploads' : 'ohne Uploads'}${item.options?.includeConfig ? ' · enthält .env' : ''} · ${verified ? 'verifiziert' : escapeHtml(item.status)}</small></div>
<code class="operation-backup-hash" title="SHA-256">${escapeHtml(String(item.sha256 || '').slice(0, 16))}…</code>
<div class="operation-backup-actions">
<a class="icon-button" href="/api/operations/backups/${encodeURIComponent(item.id)}/download" title="Herunterladen"><span data-vd-icon="download"></span></a>
<button type="button" class="icon-button" data-operation-backup-verify="${escapeHtml(item.id)}" title="Prüfen"><span data-vd-icon="scan-check"></span></button>
<button type="button" class="icon-button danger" data-operation-backup-delete="${escapeHtml(item.id)}" title="Löschen"><span data-vd-icon="trash-2"></span></button>
</div>
</article>`;
}).join('');
list.querySelectorAll('[data-operation-backup-verify]').forEach(button => button.addEventListener('click', async () => {
button.disabled = true;
try {
const result = await api('POST', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupVerify)}/verify`);
toast(result.ok ? 'Backup vollständig verifiziert.' : `Backup fehlerhaft: ${(result.errors || []).join(', ')}`);
await loadOperationsBackups();
} catch (error) { toast(error.message); }
finally { button.disabled = false; }
}));
list.querySelectorAll('[data-operation-backup-delete]').forEach(button => button.addEventListener('click', async () => {
if (!confirm('Dieses Backup endgültig löschen?')) return;
try { await api('DELETE', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupDelete)}`); toast('Backup gelöscht.'); await loadOperationsBackups(); }
catch (error) { toast(error.message); }
}));
window.VendooIcons?.render(list);
}
function renderOperationsExtensions() {
const data = operationsState.extensions || {};
const summary = document.getElementById('operations-extension-summary');
const packages = document.getElementById('operations-extension-packages');
const clients = document.getElementById('operations-extension-clients');
if (summary) summary.innerHTML = `<article><span data-vd-icon="plug-zap"></span><div><strong>${Number(data.connected || 0)} Browser live verbunden</strong><p>Erwartete Extension-Version: ${escapeHtml(data.expected_version || '—')}</p></div></article>`;
if (packages) packages.innerHTML = Object.entries(data.packages || {}).map(([browser, item]) => `<article class="${item.exists && item.version === data.expected_version ? 'is-ok' : 'is-warning'}"><span data-vd-icon="${item.exists ? 'package-check' : 'package-x'}"></span><div><strong>${escapeHtml(browser[0].toUpperCase() + browser.slice(1))}</strong><small>${item.exists ? `Paket v${escapeHtml(item.version || '?')}` : 'Paket fehlt'}${item.version && item.version !== data.expected_version ? ` · erwartet ${escapeHtml(data.expected_version)}` : ''}</small></div></article>`).join('');
if (clients) {
const rows = data.clients || [];
clients.innerHTML = rows.length ? rows.map(client => {
const live = Number(client.age_seconds || 999999) <= 180;
return `<article class="operations-extension-client${live ? ' is-live' : ''}"><span class="operation-live-dot"></span><div><strong>${escapeHtml(client.browser || 'Browser')} · v${escapeHtml(client.version || '?')}</strong><span>${escapeHtml(client.platform || 'Extension')} · zuletzt ${Number(client.age_seconds || 0) < 60 ? 'gerade eben' : `vor ${Math.max(1, Math.round(Number(client.age_seconds || 0) / 60))} Min.`}</span><small>${escapeHtml(client.server_url || '')}</small></div><b>${live ? 'Live' : 'Offline'}</b></article>`;
}).join('') : '<div class="empty-state">Noch keine Extension-Verbindung gemeldet. Öffne nach dem Update die Extension oder eine unterstützte Vinted-Seite.</div>';
}
window.VendooIcons?.render(document.getElementById('operations-extensions-panel'));
}
async function loadOperationsBackups() {
try { operationsState.backups = (await api('GET', '/api/operations/backups')).backups || []; renderOperationsBackups(); }
catch (error) { const list = document.getElementById('operations-backup-list'); if (list) list.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
}
async function loadOperationsExtensions() {
try { operationsState.extensions = await api('GET', '/api/extensions/diagnostics'); renderOperationsExtensions(); }
catch (error) { const box = document.getElementById('operations-extension-clients'); if (box) box.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
}
function renderDeploymentStatus() {
const data = operationsState.deployment || {};
const setValue = (id, value) => { const field = document.getElementById(id); if (field && document.activeElement !== field) field.value = value ?? ''; };
setValue('deployment-provider', data.provider || 'none');
setValue('deployment-coolify-url', data.coolify_url || '');
setValue('deployment-resource-uuid', data.resource_uuid || '');
setValue('deployment-trigger-mode', data.trigger_mode || 'webhook');
setValue('deployment-github-repository', data.github_repository || 'Masterluke77/vendoo');
setValue('deployment-github-branch', data.github_branch || 'main');
setValue('deployment-update-channel', data.update_channel || 'stable');
const backup = document.getElementById('deployment-backup-before'); if (backup) backup.checked = data.backup_before_deploy !== false;
const uploads = document.getElementById('deployment-backup-uploads'); if (uploads) uploads.checked = data.backup_uploads === true;
const webhook = document.getElementById('deployment-webhook-url'); if (webhook && document.activeElement !== webhook) { webhook.value = ''; webhook.placeholder = data.deploy_webhook?.configured ? `Gespeichert: ${data.deploy_webhook.masked || '••••••••'}` : 'Coolify Deploy-Webhook eintragen'; }
const token = document.getElementById('deployment-api-token'); if (token && document.activeElement !== token) { token.value = ''; token.placeholder = data.api_token?.configured ? `Gespeichert: ${data.api_token.masked || '••••••••'}` : 'Nur für API-Modus'; }
const status = document.getElementById('deployment-status');
if (status) status.innerHTML = `<strong>${data.configured ? 'Coolify ist konfiguriert' : 'Coolify noch nicht vollständig konfiguriert'}</strong><span>Provider: ${escapeHtml(data.provider || 'none')} · Trigger: ${escapeHtml(data.trigger_mode || 'webhook')} · Docker-Socket: ${data.docker_socket_access ? 'vorhanden' : 'nicht freigegeben'}</span>`;
const action = document.getElementById('deployment-action-result');
if (action && data.last_deployment) action.innerHTML = `<strong>${data.last_deployment.accepted ? 'Letzter Deployment-Auftrag angenommen' : 'Letzter Deployment-Auftrag fehlgeschlagen'}</strong><span>${operationFormatDate(data.last_deployment.requested_at)} · Branch ${escapeHtml(data.last_deployment.github_branch || 'main')} · HTTP ${escapeHtml(String(data.last_deployment.http_status || '—'))}</span>`;
}
async function loadDeploymentStatus() {
try { operationsState.deployment = await api('GET', '/api/admin/deployment'); renderDeploymentStatus(); }
catch (error) { const box = document.getElementById('deployment-status'); if (box) box.innerHTML = `<strong>Deploymentstatus konnte nicht geladen werden</strong><span>${escapeHtml(error.message)}</span>`; }
}
function deploymentSettingsPayload() {
return {
provider: document.getElementById('deployment-provider')?.value || 'none',
coolify_url: document.getElementById('deployment-coolify-url')?.value || '',
resource_uuid: document.getElementById('deployment-resource-uuid')?.value || '',
trigger_mode: document.getElementById('deployment-trigger-mode')?.value || 'webhook',
deploy_method: 'GET',
deploy_endpoint: '/api/v1/deploy?uuid={uuid}&force={force}',
github_repository: document.getElementById('deployment-github-repository')?.value || 'Masterluke77/vendoo',
github_branch: document.getElementById('deployment-github-branch')?.value || 'main',
ghcr_image: 'ghcr.io/masterluke77/vendoo',
update_channel: document.getElementById('deployment-update-channel')?.value || 'stable',
backup_before_deploy: document.getElementById('deployment-backup-before')?.checked !== false,
backup_uploads: document.getElementById('deployment-backup-uploads')?.checked === true,
api_token: document.getElementById('deployment-api-token')?.value || '',
deploy_webhook_url: document.getElementById('deployment-webhook-url')?.value || '',
};
}
async function loadOperationsCenter() {
if (!document.getElementById('operations-center-title')) return;
try { operationsState.status = await api('GET', '/api/operations/status'); renderOperationsStatus(); } catch (error) { toast(error.message); }
await Promise.all([loadOperationsBackups(), loadOperationsExtensions(), loadDeploymentStatus()]);
}
function setOperationsTab(target) {
document.querySelectorAll('.operations-tab').forEach(button => button.classList.toggle('active', button.dataset.operationsTab === target));
document.querySelectorAll('.operations-panel').forEach(panel => panel.classList.toggle('active', panel.id === `operations-${target}-panel`));
}
function setupOperationsCenter() {
const hour = document.getElementById('operations-auto-hour');
if (hour && !hour.options.length) hour.innerHTML = Array.from({ length: 24 }, (_, index) => `<option value="${index}">${String(index).padStart(2, '0')}:00 Uhr</option>`).join('');
document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', () => setOperationsTab(button.dataset.operationsTab)));
document.getElementById('operations-refresh-btn')?.addEventListener('click', loadOperationsCenter);
document.getElementById('operations-extension-refresh')?.addEventListener('click', loadOperationsExtensions);
document.getElementById('operations-backup-create')?.addEventListener('click', async event => {
const button = event.currentTarget;
button.disabled = true; const old = button.innerHTML; button.textContent = 'Backup wird erstellt und geprüft …';
try {
const result = await api('POST', '/api/operations/backups', {
label: document.getElementById('operations-backup-label')?.value || '',
include_uploads: document.getElementById('operations-backup-uploads')?.checked !== false,
include_config: document.getElementById('operations-backup-config')?.checked === true,
});
toast(`Backup verifiziert: ${result.filename}`);
document.getElementById('operations-backup-label').value = '';
await loadOperationsBackups();
} catch (error) { toast(error.message || 'Backup fehlgeschlagen.'); }
finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); }
});
document.getElementById('operations-settings-save')?.addEventListener('click', async () => {
try {
operationsState.status.settings = await api('PUT', '/api/operations/settings', {
auto_backup_enabled: document.getElementById('operations-auto-enabled')?.checked === true,
auto_backup_hour: Number(document.getElementById('operations-auto-hour')?.value || 3),
auto_backup_retention: Number(document.getElementById('operations-auto-retention')?.value || 7),
include_uploads_automatic: document.getElementById('operations-auto-uploads')?.checked !== false,
});
renderOperationsStatus(); toast('Backup-Zeitplan gespeichert.');
} catch (error) { toast(error.message); }
});
const restoreInput = document.getElementById('operations-restore-input');
restoreInput?.addEventListener('change', () => {
operationsState.restoreFile = restoreInput.files?.[0] || null;
document.getElementById('operations-restore-file').textContent = operationsState.restoreFile ? `${operationsState.restoreFile.name} · ${operationFormatBytes(operationsState.restoreFile.size)}` : 'Keine Datei ausgewählt.';
document.getElementById('operations-restore-stage').disabled = !operationsState.restoreFile;
});
document.getElementById('operations-restore-stage')?.addEventListener('click', async event => {
if (!operationsState.restoreFile || !confirm('Backup vollständig prüfen und Wiederherstellung vorbereiten? Es wird automatisch ein Sicherheitsbackup erstellt.')) return;
const button = event.currentTarget; button.disabled = true; button.textContent = 'Backup wird geprüft …';
try { const result = await operationsUploadRequest('/api/operations/restore/stage', 'backup', operationsState.restoreFile); toast(result.message); operationsState.restoreFile = null; restoreInput.value = ''; await loadOperationsCenter(); }
catch (error) { toast(error.message); }
finally { button.disabled = !operationsState.restoreFile; button.textContent = 'Wiederherstellung prüfen und vorbereiten'; }
});
document.getElementById('operations-update-url-save')?.addEventListener('click', async () => {
try { operationsState.status.settings = await api('PUT', '/api/operations/settings', { update_manifest_url: document.getElementById('operations-update-url')?.value || '' }); renderOperationsStatus(); toast('Update-Manifest-URL gespeichert.'); }
catch (error) { toast(error.message); }
});
document.getElementById('operations-update-check')?.addEventListener('click', async event => {
const resultBox = document.getElementById('operations-update-check-result'); const button = event.currentTarget; button.disabled = true; if (resultBox) resultBox.textContent = 'Update wird geprüft …';
try {
const result = await api('POST', '/api/operations/updates/check');
if (resultBox) resultBox.innerHTML = result.configured ? `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : 'Vendoo ist aktuell'}</strong><span>Installiert: ${escapeHtml(result.current_version)}${result.latest_version ? ` · Verfügbar: ${escapeHtml(result.latest_version)}` : ''}</span>` : `<strong>Keine Online-Prüfung konfiguriert</strong><span>${escapeHtml(result.message || '')}</span>`;
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Prüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
finally { button.disabled = false; }
});
const updateInput = document.getElementById('operations-update-input');
updateInput?.addEventListener('change', () => {
operationsState.updateFile = updateInput.files?.[0] || null;
document.getElementById('operations-update-file').textContent = operationsState.updateFile ? `${operationsState.updateFile.name} · ${operationFormatBytes(operationsState.updateFile.size)}` : 'Keine Datei ausgewählt.';
document.getElementById('operations-update-stage').disabled = !operationsState.updateFile;
});
document.getElementById('operations-update-stage')?.addEventListener('click', async event => {
if (!operationsState.updateFile || !confirm('Update prüfen, Sicherheitsbackup erzeugen und für den nächsten Setup-Lauf vorbereiten?')) return;
const button = event.currentTarget; button.disabled = true; button.textContent = 'Update wird geprüft …';
try { const result = await operationsUploadRequest('/api/operations/updates/stage', 'update', operationsState.updateFile); toast(result.message); operationsState.updateFile = null; updateInput.value = ''; await loadOperationsCenter(); }
catch (error) { toast(error.message); }
finally { button.disabled = !operationsState.updateFile; button.textContent = 'Update prüfen und vorbereiten'; }
});
document.getElementById('deployment-save')?.addEventListener('click', async event => {
const button = event.currentTarget; button.disabled = true;
try { operationsState.deployment = await api('PUT', '/api/admin/deployment', deploymentSettingsPayload()); renderDeploymentStatus(); toast('Coolify- und GitHub-Konfiguration gespeichert.'); }
catch (error) { toast(error.message); }
finally { button.disabled = false; }
});
document.getElementById('deployment-test')?.addEventListener('click', async event => {
const button = event.currentTarget; const resultBox = document.getElementById('deployment-status'); button.disabled = true;
try { const result = await api('POST', '/api/admin/deployment/test'); if (resultBox) resultBox.innerHTML = `<strong>Coolify erreichbar</strong><span>Healthcheck HTTP ${escapeHtml(String(result.status || 200))}</span>`; }
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Coolify nicht erreichbar</strong><span>${escapeHtml(error.message)}</span>`; }
finally { button.disabled = false; }
});
document.getElementById('deployment-update-check')?.addEventListener('click', async event => {
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true;
try { const result = await api('POST', '/api/admin/deployment/updates/check'); if (resultBox) resultBox.innerHTML = `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}</strong><span>Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.message ? ` · ${escapeHtml(result.message)}` : ''}</span>`; }
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Updateprüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
finally { button.disabled = false; }
});
document.getElementById('deployment-trigger')?.addEventListener('click', async event => {
const confirmation = prompt('Vor dem Deployment wird sofern aktiviert ein Backup erzeugt. Coolify zieht danach den konfigurierten GitHub-Branch. Zum Fortfahren exakt DEPLOY eingeben:');
if (confirmation !== 'DEPLOY') return;
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true; button.textContent = 'Deployment wird angefordert …';
try {
const result = await api('POST', '/api/admin/deployment/trigger', { confirmation, force: false, reason: 'update-center' });
if (resultBox) resultBox.innerHTML = `<strong>Coolify-Deployment angenommen</strong><span>${escapeHtml(result.message || '')}${result.backup_file ? ` · Backup: ${escapeHtml(result.backup_file)}` : ''}</span>`;
toast('Coolify-Deployment wurde angefordert.');
await loadDeploymentStatus();
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Deployment fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; toast(error.message); }
finally { button.disabled = false; button.textContent = 'Coolify-Deployment starten'; }
});
}
// --- Admin ---
function setupAdmin() {
// Admin tab switching
document.querySelectorAll('.admin-tab').forEach(tab => {
tab.addEventListener('click', () => {
const target = tab.dataset.adminTab;
switchAdminTab(target);
if (target === 'users') loadAdminUsers();
if (target === 'roles') loadRoleMatrix();
if (target === 'sessions') loadAdminSessions();
if (target === 'smtp') loadSmtpSettings();
if (target === 'system') { loadSystemInfo(); loadOperationsCenter(); }
if (target === 'modules') globalThis.VendooModuleManager?.load?.();
if (target === 'security') loadSecurityCenter();
if (target === 'audit') loadAdminAudit();
});
});
document.getElementById('invite-user-btn')?.addEventListener('click', () => {
document.getElementById('invite-email').value = '';
document.getElementById('invite-name').value = '';
document.getElementById('invite-role').value = 'editor';
document.getElementById('invite-result-wrap').classList.add('hidden');
document.getElementById('invite-result-password').value = '';
const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]');
if (mlRadio) mlRadio.checked = true;
document.getElementById('invite-send-btn').textContent = 'Einladung senden';
document.getElementById('invite-send-btn').onclick = sendInvite;
document.getElementById('invite-modal').classList.remove('hidden');
});
document.getElementById('invite-copy-pw')?.addEventListener('click', () => {
const pw = document.getElementById('invite-result-password').value;
if (pw) { navigator.clipboard.writeText(pw); toast('Passwort kopiert!'); }
});
document.getElementById('invite-send-btn')?.addEventListener('click', sendInvite);
document.getElementById('edit-user-save-btn')?.addEventListener('click', saveUserEdit);
// SMTP
document.getElementById('smtp-save-btn')?.addEventListener('click', saveSmtpSettings);
document.getElementById('smtp-test-btn')?.addEventListener('click', testSmtpConnection);
document.getElementById('smtp-pass-toggle')?.addEventListener('click', () => {
const inp = document.getElementById('smtp-pass');
const isPw = inp.type === 'password';
inp.type = isPw ? 'text' : 'password';
document.getElementById('smtp-pass-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen';
});
// Edit user password toggle
document.getElementById('edit-user-pw-toggle')?.addEventListener('click', () => {
const inp = document.getElementById('edit-user-password');
const isPw = inp.type === 'password';
inp.type = isPw ? 'text' : 'password';
document.getElementById('edit-user-pw-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen';
});
// System tab
document.getElementById('system-cleanup-btn')?.addEventListener('click', async () => {
try {
const res = await api('POST', '/api/admin/sessions/cleanup');
toast(res.message || 'Sessions bereinigt');
loadSystemInfo();
} catch (err) { toast('Fehler: ' + err.message); }
});
document.getElementById('system-diagnostics-btn')?.addEventListener('click', runSystemDiagnostics);
document.getElementById('system-diagnostics-copy')?.addEventListener('click', copySystemDiagnostics);
document.getElementById('security-refresh-btn')?.addEventListener('click', loadSecurityCenter);
document.getElementById('audit-filter-btn')?.addEventListener('click', () => { auditState.page = 1; loadAdminAudit(); });
document.getElementById('audit-search')?.addEventListener('keydown', event => { if (event.key === 'Enter') { auditState.page = 1; loadAdminAudit(); } });
document.getElementById('audit-prev')?.addEventListener('click', () => { if (auditState.page > 1) { auditState.page -= 1; loadAdminAudit(); } });
document.getElementById('audit-next')?.addEventListener('click', () => { if (auditState.page < auditState.pages) { auditState.page += 1; loadAdminAudit(); } });
document.getElementById('audit-export-btn')?.addEventListener('click', exportAdminAudit);
setupOperationsCenter();
}
function switchAdminTab(target) {
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.adminTab === target));
document.querySelectorAll('.admin-panel').forEach(p => p.classList.remove('active'));
document.getElementById(`admin-${target}-panel`)?.classList.add('active');
}
function timeAgo(dateStr) {
if (!dateStr) return 'Nie';
const diff = Date.now() - new Date(dateStr + 'Z').getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'Gerade eben';
if (min < 60) return `Vor ${min}m`;
const hrs = Math.floor(min / 60);
if (hrs < 24) return `Vor ${hrs}h`;
const days = Math.floor(hrs / 24);
return `Vor ${days}d`;
}
function getInitials(name, email) {
const src = name || email || '?';
return src.split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join('');
}
const ROLE_LABELS = { admin: 'Administrator', manager: 'Manager', editor: 'Editor', publisher: 'Publisher', viewer: 'Leser' };
let adminUsersCache = [];
async function loadRoleMatrix() {
const cards = document.getElementById('role-cards');
const matrix = document.getElementById('role-permission-matrix');
if (!cards || !matrix) return;
try {
const data = await api('GET', '/api/security/roles');
cards.innerHTML = data.roles.map(role => `<article class="role-card role-${escapeHtml(role.key)}"><header><span class="role-badge ${escapeHtml(role.key)}">${escapeHtml(role.label)}</span><strong>${role.permissions.length} Rechte</strong></header><p>${escapeHtml(role.description)}</p></article>`).join('');
const groups = [...new Set(data.permissions.map(permission => permission.group))];
matrix.innerHTML = groups.map(group => {
const permissions = data.permissions.filter(permission => permission.group === group);
return `<section class="role-matrix-group"><h4>${escapeHtml(group)}</h4><div class="role-matrix-table"><div class="role-matrix-head"><span>Berechtigung</span>${data.roles.map(role => `<strong>${escapeHtml(role.label)}</strong>`).join('')}</div>${permissions.map(permission => `<div class="role-matrix-row"><span>${escapeHtml(permission.label)}</span>${data.roles.map(role => `<i class="${role.permissions.includes(permission.key) ? 'is-allowed' : 'is-denied'}" title="${role.permissions.includes(permission.key) ? 'Erlaubt' : 'Nicht erlaubt'}">${role.permissions.includes(permission.key) ? '✓' : '—'}</i>`).join('')}</div>`).join('')}</div></section>`;
}).join('');
} catch (error) {
cards.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
matrix.innerHTML = '';
}
}
async function forceReleaseSecurityLock(type, id) {
if (!confirm('Bearbeitungssperre wirklich administrativ aufheben?')) return;
try { await api('DELETE', `/api/security/locks/${encodeURIComponent(type)}/${encodeURIComponent(id)}`); toast('Sperre aufgehoben'); loadSecurityCenter(); }
catch (error) { toast(error.message); }
}
async function loadSecurityCenter() {
const summary = document.getElementById('security-status-summary');
if (!summary) return;
summary.innerHTML = '<div class="empty-state">Sicherheitsstatus wird geladen …</div>';
try {
const status = await api('GET', '/api/security/status');
const secure = !status.remote_binding || status.secure_public_url;
summary.innerHTML = `<div class="security-score ${secure && !status.warnings.length ? 'is-good' : 'is-warning'}"><span data-vd-icon="${secure ? 'shield-check' : 'shield-alert'}"></span><div><strong>${secure && !status.warnings.length ? 'Sicherheitsbasis aktiv' : 'Konfiguration braucht Aufmerksamkeit'}</strong><p>Vendoo ${escapeHtml(status.version)} · Bind ${escapeHtml(status.bind_host)}:${status.port} · ${status.trust_proxy ? 'Reverse-Proxy vertraut' : 'Direkte Verbindung'}</p></div></div>`;
const warnings = document.getElementById('security-warning-list');
warnings.innerHTML = status.warnings.length ? status.warnings.map(message => `<div class="security-warning"><span data-vd-icon="triangle-alert"></span><p>${escapeHtml(message)}</p></div>`).join('') : '<div class="security-ok"><span data-vd-icon="badge-check"></span> Keine kritischen Konfigurationswarnungen erkannt.</div>';
document.getElementById('security-network-info').innerHTML = `<dl><dt>Bind-Adresse</dt><dd><code>${escapeHtml(status.bind_host)}</code></dd><dt>Öffentliche URL</dt><dd>${status.public_base_url ? `<code>${escapeHtml(status.public_base_url)}</code>` : 'Nicht gesetzt'}</dd><dt>HTTPS</dt><dd>${status.secure_public_url ? 'Aktiv' : 'Nicht erkannt'}</dd><dt>Erlaubte Hosts</dt><dd>${status.allowed_hosts.length ? status.allowed_hosts.map(escapeHtml).join(', ') : 'Nur lokale/private Hosts'}</dd><dt>Erlaubte Origins</dt><dd>${status.allowed_origins.length ? status.allowed_origins.map(escapeHtml).join(', ') : 'Nur Same-Origin und Extensions'}</dd></dl>`;
document.getElementById('security-session-info').innerHTML = `<dl><dt>Absolute Sessiondauer</dt><dd>${status.session_days} Tage</dd><dt>Inaktivitätslimit</dt><dd>${status.session_idle_minutes} Minuten</dd><dt>Rate-Limit erlaubt</dt><dd>${status.rate_limits.allowed}</dd><dt>Rate-Limit blockiert</dt><dd>${status.rate_limits.blocked}</dd><dt>Aktive Sperren</dt><dd>${status.active_locks.length}</dd></dl>`;
const secretInfo = document.getElementById('security-secret-info');
if (secretInfo) secretInfo.innerHTML = `<dl><dt>Provider</dt><dd>${escapeHtml(status.secrets.provider)}</dd><dt>Verschlüsselt</dt><dd>${status.secrets.encrypted}</dd><dt>Umgebungsvariablen</dt><dd>${status.secrets.environment}</dd><dt>Konfiguriert</dt><dd>${status.secrets.configured}</dd></dl><div class="security-secret-list">${status.secrets.secrets.filter(item => item.configured).map(item => `<span class="status-badge">${escapeHtml(item.label)} · ${escapeHtml(item.provider)}</span>`).join('') || '<span class="muted">Keine Secrets konfiguriert.</span>'}</div>`;
const requestInfo = document.getElementById('security-request-info');
if (requestInfo) requestInfo.innerHTML = `<dl><dt>Requests seit Start</dt><dd>${status.requests.total}</dd><dt>Aktiv</dt><dd>${status.requests.active}</dd><dt>Serverfehler</dt><dd>${status.requests.errors}</dd><dt>Sicherheitsblockaden</dt><dd>${status.requests.blocked}</dd><dt>Inline-Skripte</dt><dd>${status.csp.script_inline_allowed ? 'Erlaubt' : 'Blockiert'}</dd><dt>CSP</dt><dd>${status.csp.enforced ? 'Erzwungen' : 'Nur Bericht'}</dd></dl>`;
const locks = document.getElementById('security-lock-list');
locks.innerHTML = status.active_locks.length ? status.active_locks.map(lock => `<article class="security-lock-row"><div><strong>${escapeHtml(lock.resource_type)} #${escapeHtml(lock.resource_id)}</strong><p>${escapeHtml(lock.user_name || lock.user_email)} · bis ${new Date(lock.expires_at).toLocaleTimeString('de-DE')}</p></div><button type="button" class="small-btn danger-btn" data-force-lock-type="${escapeHtml(lock.resource_type)}" data-force-lock-id="${escapeHtml(lock.resource_id)}">Aufheben</button></article>`).join('') : '<div class="empty-state">Keine aktiven Bearbeitungssperren.</div>';
locks.querySelectorAll('[data-force-lock-type]').forEach(button => button.addEventListener('click', () => forceReleaseSecurityLock(button.dataset.forceLockType, button.dataset.forceLockId)));
globalThis.VendooIcons?.render?.();
} catch (error) { summary.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
}
const auditState = { page: 1, pages: 1, total: 0 };
function buildAuditQuery() {
const size = Number(document.getElementById('audit-page-size')?.value || 100);
const params = new URLSearchParams({ limit: String(size), offset: String((auditState.page - 1) * size) });
const values = {
q: document.getElementById('audit-search')?.value.trim(),
action: document.getElementById('audit-action-filter')?.value.trim(),
from: document.getElementById('audit-from')?.value,
to: document.getElementById('audit-to')?.value ? `${document.getElementById('audit-to').value} 23:59:59` : '',
};
for (const [key, value] of Object.entries(values)) if (value) params.set(key, value);
return params;
}
function exportAdminAudit() {
const params = buildAuditQuery();
params.delete('limit'); params.delete('offset');
window.location.href = `/api/admin/audit/export.csv?${params.toString()}`;
}
async function loadAdminUsers() {
try {
const users = await api('GET', '/api/admin/users');
adminUsersCache = Array.isArray(users) ? users : [];
const container = document.getElementById('users-list');
if (!container) return;
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; return; }
container.innerHTML = adminUsersCache.map(u => {
const initials = getInitials(u.name, u.email);
const isOnline = u.last_active_at && (Date.now() - new Date(`${u.last_active_at}Z`).getTime()) < 5 * 60 * 1000;
const dotClass = !u.active ? 'inactive' : isOnline ? 'online' : 'offline';
const lockedBadge = u.locked_until && Date.parse(u.locked_until) > Date.now() ? '<span class="pw-indicator no-pw">Vorübergehend gesperrt</span>' : '';
const pwBadge = u.has_password
? '<span class="pw-indicator has-pw">Passwort gesetzt</span>'
: '<span class="pw-indicator no-pw">Kein Passwort</span>';
return `<div class="user-card" data-user-card="${u.id}">
<div class="user-card-avatar" style="background:${escapeHtml(u.avatar_color || 'var(--accent)')}">${escapeHtml(initials)}</div>
<div class="user-card-info">
<div class="name">${escapeHtml(u.name || u.email)} <span class="role-badge ${escapeHtml(u.role)}">${escapeHtml(ROLE_LABELS[u.role] || u.role)}</span> ${pwBadge} ${lockedBadge}</div>
<div class="email">${escapeHtml(u.email)}</div>
<div class="meta"><span class="status-dot ${dotClass}"></span> ${u.active ? timeAgo(u.last_active_at) : 'Deaktiviert'} · Erstellt: ${new Date(`${u.created_at}Z`).toLocaleDateString('de-DE')}</div>
</div>
<div class="user-card-actions">
<button type="button" class="small-btn" data-user-edit="${u.id}" title="Bearbeiten" aria-label="${escapeHtml(u.name || u.email)} bearbeiten"><span data-vd-icon="pencil"></span></button>
<button type="button" class="small-btn" data-user-resend="${u.id}" title="Einladung erneut senden" aria-label="Einladung erneut senden"><span data-vd-icon="mail"></span></button>
${u.id !== currentUser?.id ? `<button type="button" class="small-btn danger-btn" data-user-delete="${u.id}" title="Löschen" aria-label="${escapeHtml(u.name || u.email)} löschen"><span data-vd-icon="trash-2"></span></button>` : ''}
</div>
</div>`;
}).join('');
container.querySelectorAll('[data-user-edit]').forEach(button => button.addEventListener('click', () => openEditUser(Number(button.dataset.userEdit))));
container.querySelectorAll('[data-user-resend]').forEach(button => button.addEventListener('click', () => resendInvite(Number(button.dataset.userResend))));
container.querySelectorAll('[data-user-delete]').forEach(button => button.addEventListener('click', () => {
const user = adminUsersCache.find(entry => Number(entry.id) === Number(button.dataset.userDelete));
if (user) confirmDeleteUser(user.id, user.email);
}));
globalThis.VendooIcons?.render?.(container);
} catch (err) { toast('Fehler: ' + err.message); }
}
async function sendInvite() {
const email = document.getElementById('invite-email').value.trim();
const name = document.getElementById('invite-name').value.trim();
const role = document.getElementById('invite-role').value;
const method = document.querySelector('input[name="invite-method"]:checked')?.value || 'magic_link';
if (!email) return toast('E-Mail erforderlich');
try {
const result = await api('POST', '/api/admin/users/invite', { email, name, role, method });
if (method === 'auto_password' && result.password) {
// Show generated password
const resultWrap = document.getElementById('invite-result-wrap');
const pwInput = document.getElementById('invite-result-password');
pwInput.value = result.password;
resultWrap.classList.remove('hidden');
document.getElementById('invite-send-btn').textContent = 'Fertig';
document.getElementById('invite-send-btn').onclick = () => {
document.getElementById('invite-modal').classList.add('hidden');
resultWrap.classList.add('hidden');
document.getElementById('invite-send-btn').textContent = 'Einladung senden';
document.getElementById('invite-send-btn').onclick = sendInvite;
};
toast(result.consoleFallback ? 'Account erstellt — Zugangsdaten in Konsole' : 'Account erstellt & Zugangsdaten gesendet');
} else {
document.getElementById('invite-modal').classList.add('hidden');
if (result.consoleFallback) {
toast('Einladung erstellt — Link in Server-Konsole');
} else {
toast('Einladung gesendet an ' + email);
}
}
loadAdminUsers();
} catch (err) { toast('Fehler: ' + err.message); }
}
async function resendInvite(userId) {
try {
const result = await api('POST', '/api/admin/users/resend-invite', { user_id: userId });
if (result.consoleFallback) {
toast('Link in Server-Konsole ausgegeben');
} else {
toast('Einladung erneut gesendet');
}
} catch (err) { toast('Fehler: ' + err.message); }
}
async function openEditUser(userId) {
try {
let user = adminUsersCache.find(entry => Number(entry.id) === Number(userId));
if (!user) {
const users = await api('GET', '/api/admin/users');
adminUsersCache = Array.isArray(users) ? users : [];
user = adminUsersCache.find(entry => Number(entry.id) === Number(userId));
}
if (!user) throw new Error('Benutzer wurde nicht gefunden.');
document.getElementById('edit-user-id').value = user.id;
const emailField = document.getElementById('edit-user-email');
if (emailField) emailField.value = user.email || '';
document.getElementById('edit-user-name').value = user.name || '';
document.getElementById('edit-user-role').value = user.role;
document.getElementById('edit-user-active').value = user.active ? '1' : '0';
document.getElementById('edit-user-password').value = '';
document.getElementById('edit-user-password').type = 'password';
document.getElementById('edit-user-pw-toggle').textContent = 'Zeigen';
// Password status
const pwStatus = document.getElementById('edit-user-pw-status');
if (user.has_password) {
pwStatus.textContent = 'Passwort gesetzt';
pwStatus.className = 'pw-status-badge has-pw';
} else {
pwStatus.textContent = 'Kein Passwort';
pwStatus.className = 'pw-status-badge no-pw';
}
// Login history
const historyEl = document.getElementById('edit-user-login-history');
historyEl.classList.add('hidden');
historyEl.innerHTML = '';
const showHistBtn = document.getElementById('edit-user-show-history');
showHistBtn.onclick = async () => {
if (!historyEl.classList.contains('hidden')) {
historyEl.classList.add('hidden');
showHistBtn.textContent = 'Login-Verlauf anzeigen';
return;
}
try {
const history = await api('GET', `/api/admin/users/${userId}/login-history`);
if (!history.length) {
historyEl.innerHTML = '<div style="padding:10px;font-size:12px;color:var(--text-muted);text-align:center">Keine Login-Einträge</div>';
} else {
historyEl.innerHTML = history.map(h => {
const dt = new Date(h.created_at + 'Z').toLocaleString('de-DE');
const statusClass = h.success ? 'success' : 'failed';
const statusText = h.success ? 'Erfolgreich' : 'Fehlgeschlagen';
return `<div class="login-history-item">
<span class="lh-time">${dt}</span>
<span class="lh-ip">${h.ip || '—'}</span>
<span class="lh-status ${statusClass}">${statusText}</span>
</div>`;
}).join('');
}
historyEl.classList.remove('hidden');
showHistBtn.textContent = 'Verlauf ausblenden';
} catch (err) { toast('Fehler: ' + err.message); }
};
document.getElementById('edit-user-modal').classList.remove('hidden');
} catch (err) { toast('Fehler: ' + err.message); }
}
async function saveUserEdit() {
const id = Number(document.getElementById('edit-user-id').value);
const name = document.getElementById('edit-user-name').value.trim();
const role = document.getElementById('edit-user-role').value;
const active = Number(document.getElementById('edit-user-active').value);
const password = document.getElementById('edit-user-password').value;
const button = document.getElementById('edit-user-save-btn');
if (!Number.isInteger(id) || id <= 0) return toast('Ungültiger Benutzer. Bitte die Liste neu laden.');
if (!name) return toast('Bitte einen Namen eintragen.');
if (password && (password.length < 10 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password) || !/[^A-Za-z0-9]/.test(password))) {
return toast('Passwort braucht mindestens 10 Zeichen, Groß-/Kleinbuchstaben, Zahl und Sonderzeichen');
}
const oldLabel = button?.textContent || 'Speichern';
if (button) { button.disabled = true; button.textContent = 'Wird gespeichert …'; }
try {
const updated = await api('PUT', `/api/admin/users/${id}`, { name, role, active });
if (password) await api('POST', `/api/admin/users/${id}/reset-password`, { password });
adminUsersCache = adminUsersCache.map(entry => Number(entry.id) === id ? { ...entry, ...updated, has_password: password ? 1 : entry.has_password } : entry);
toast(password ? 'Benutzer aktualisiert und Passwort zurückgesetzt.' : 'Benutzer aktualisiert.');
document.getElementById('edit-user-modal').classList.add('hidden');
await loadAdminUsers();
} catch (err) { toast('Fehler: ' + err.message); }
finally { if (button) { button.disabled = false; button.textContent = oldLabel; } }
}
async function confirmDeleteUser(userId, email) {
if (!confirm(`User "${email}" wirklich löschen? Alle Sessions werden beendet.`)) return;
try {
await api('DELETE', `/api/admin/users/${userId}`);
toast('User gelöscht');
loadAdminUsers();
} catch (err) { toast('Fehler: ' + err.message); }
}
async function loadAdminSessions() {
try {
const sessions = await api('GET', '/api/admin/sessions');
const container = document.getElementById('sessions-list');
if (!sessions.length) { container.innerHTML = '<div class="empty-state">Keine aktiven Sessions</div>'; return; }
container.innerHTML = sessions.map(s => {
const ua = parseUA(s.user_agent);
return `<div class="session-row">
<div class="session-info">
<strong>${s.name || s.email}</strong> <span class="role-badge ${s.role}">${s.role}</span>
<div class="session-ua">${ua}</div>
<div class="session-ip">IP: ${s.ip || 'unbekannt'}</div>
</div>
<div class="session-meta">
${timeAgo(s.created_at)}<br>
<small>Läuft ab: ${new Date(s.expires_at.endsWith('Z') ? s.expires_at : s.expires_at + 'Z').toLocaleDateString('de-DE')}</small>
</div>
<button class="small-btn" data-kill-session-id="${s.id}" style="color:var(--danger)" title="Session beenden">✕</button>
</div>`;
}).join('');
} catch (err) { toast('Fehler: ' + err.message); }
}
async function killSession(sessionId) {
try {
await api('DELETE', `/api/admin/sessions/${sessionId}`);
toast('Session beendet');
loadAdminSessions();
} catch (err) { toast('Fehler: ' + err.message); }
}
async function loadAdminAudit() {
try {
const params = buildAuditQuery();
const result = await api('GET', `/api/admin/audit?${params.toString()}`);
const logs = result.rows || [];
const size = Number(result.limit || 100);
auditState.total = Number(result.total || 0);
auditState.pages = Math.max(1, Math.ceil(auditState.total / size));
if (auditState.page > auditState.pages) auditState.page = auditState.pages;
const container = document.getElementById('audit-list');
document.getElementById('audit-summary').textContent = `${auditState.total.toLocaleString('de-DE')} protokollierte Ereignisse`;
document.getElementById('audit-page-info').textContent = `Seite ${auditState.page} von ${auditState.pages}`;
document.getElementById('audit-prev').disabled = auditState.page <= 1;
document.getElementById('audit-next').disabled = auditState.page >= auditState.pages;
if (!logs.length) { container.innerHTML = '<div class="empty-state">Keine passenden Audit-Einträge</div>'; return; }
container.innerHTML = logs.map(l => {
const actionParts = (l.action || '').split('.');
const category = actionParts[0] || 'other';
return `<div class="audit-row"><div class="audit-time">${new Date(String(l.created_at).replace(' ', 'T') + 'Z').toLocaleString('de-DE')}</div><div class="audit-user"><strong>${escapeHtml(l.user_email || 'System')}</strong><small>${escapeHtml(l.ip || '')}</small></div><div class="audit-action"><span class="audit-action-badge ${escapeHtml(category)}">${escapeHtml(l.action)}</span>${l.target_type ? `<small>${escapeHtml(l.target_type)} ${escapeHtml(l.target_id || '')}</small>` : ''}${l.details ? `<p>${escapeHtml(l.details)}</p>` : ''}</div></div>`;
}).join('');
} catch (err) { toast('Fehler: ' + err.message); }
}
// --- SMTP Settings ---
async function loadSmtpSettings() {
try {
const smtp = await api('GET', '/api/admin/smtp');
document.getElementById('smtp-host').value = smtp.smtp_host || '';
document.getElementById('smtp-port').value = smtp.smtp_port || '587';
document.getElementById('smtp-user').value = smtp.smtp_user || '';
document.getElementById('smtp-pass').value = '';
document.getElementById('smtp-pass').placeholder = smtp.smtp_pass ? 'Gesetzt (leer = beibehalten)' : 'Passwort';
document.getElementById('smtp-from').value = smtp.smtp_from || '';
const dot = document.getElementById('smtp-status-dot');
const text = document.getElementById('smtp-status-text');
if (smtp.configured) {
dot.className = 'smtp-status-dot configured';
text.textContent = `SMTP konfiguriert (Quelle: ${smtp.source === 'db' ? 'Datenbank' : smtp.source === 'env' ? '.env Datei' : 'Nicht konfiguriert'})`;
} else {
dot.className = 'smtp-status-dot not-configured';
text.textContent = 'SMTP nicht konfiguriert — E-Mails werden in der Konsole ausgegeben';
}
} catch (err) { toast('Fehler: ' + err.message); }
}
async function saveSmtpSettings() {
const data = {
smtp_host: document.getElementById('smtp-host').value.trim(),
smtp_port: document.getElementById('smtp-port').value.trim(),
smtp_user: document.getElementById('smtp-user').value.trim(),
smtp_from: document.getElementById('smtp-from').value.trim(),
};
const pass = document.getElementById('smtp-pass').value;
if (pass) data.smtp_pass = pass;
try {
const btn = document.getElementById('smtp-save-btn');
btn.disabled = true; btn.textContent = 'Speichere...';
await api('PUT', '/api/admin/smtp', data);
toast('SMTP-Einstellungen gespeichert');
loadSmtpSettings();
} catch (err) { toast('Fehler: ' + err.message); }
finally {
const btn = document.getElementById('smtp-save-btn');
btn.disabled = false; btn.textContent = 'SMTP speichern';
}
}
async function testSmtpConnection() {
const btn = document.getElementById('smtp-test-btn');
btn.disabled = true; btn.textContent = 'Sende Testmail...';
try {
const res = await api('POST', '/api/admin/smtp/test');
toast(res.message || 'Testmail gesendet!');
} catch (err) { toast('Fehler: ' + err.message); }
finally { btn.disabled = false; btn.textContent = 'Verbindung testen'; }
}
// --- Batch Image Factory ---
let imageFactoryPollTimer = null;
let imageFactoryEditingProfileId = null;
function imageFactoryStatusMeta(status) {
return ({
queued: ['Wartend', 'queued'], running: ['Läuft', 'running'], paused: ['Pausiert', 'paused'],
completed: ['Fertig', 'completed'], partial: ['Teilweise fertig', 'partial'], failed: ['Fehlgeschlagen', 'failed'],
cancelled: ['Abgebrochen', 'cancelled'],
})[status] || [status || 'Unbekannt', 'queued'];
}
function updateImageFactorySelection() {
const sources = state.imageFactorySelectedSources.size;
const profiles = state.imageFactorySelectedProfiles.size;
const outputs = sources * profiles;
document.getElementById('image-factory-selection-count')?.replaceChildren(document.createTextNode(`${sources} Bilder · ${profiles} Profile`));
document.getElementById('image-factory-output-estimate')?.replaceChildren(document.createTextNode(outputs ? `${outputs} neue Bilddateien werden erzeugt` : 'Noch keine Ausgabe geplant'));
const submit = document.getElementById('image-factory-submit');
if (submit) submit.disabled = !sources || !profiles;
}
function renderImageFactorySources() {
const container = document.getElementById('image-factory-source-grid');
if (!container) return;
if (!state.imageFactorySources.length) {
container.innerHTML = '<div class="empty-state">Keine passenden Bilder gefunden. Passe Suche oder Quellenfilter an.</div>';
return;
}
container.innerHTML = state.imageFactorySources.map(item => {
const selected = state.imageFactorySelectedSources.has(item.image_path);
const title = item.title || item.prompt || item.image_path.split('/').pop();
return `<label class="image-factory-source-card${selected ? ' is-selected' : ''}">
<input type="checkbox" data-image-factory-source="${escapeHtml(item.image_path)}" ${selected ? 'checked' : ''}>
<span class="image-factory-source-check">${window.VendooIcons?.svg('check', { size: 13 }) || '✓'}</span>
<img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="${escapeHtml(title)}" loading="lazy">
<span class="image-factory-source-caption"><strong>${escapeHtml(title)}</strong><small>${escapeHtml(MEDIA_SOURCE_LABELS[item.source] || item.source || 'Bild')}</small></span>
</label>`;
}).join('');
container.querySelectorAll('[data-image-factory-source]').forEach(input => input.addEventListener('change', () => {
if (input.checked) state.imageFactorySelectedSources.add(input.dataset.imageFactorySource);
else state.imageFactorySelectedSources.delete(input.dataset.imageFactorySource);
input.closest('.image-factory-source-card')?.classList.toggle('is-selected', input.checked);
updateImageFactorySelection();
}));
}
function profileDimensionLabel(profile) {
const resize = profile.operations?.resize || {};
const dimensions = resize.width || resize.height ? `${resize.width || 'auto'} × ${resize.height || 'auto'}` : 'Originalgröße';
return `${dimensions} · ${(profile.operations?.format || 'jpeg').toUpperCase()} · Q${profile.operations?.quality || 90}`;
}
function renderImageFactoryProfiles() {
const container = document.getElementById('image-factory-profile-grid');
if (!container) return;
if (!state.imageFactoryProfiles.length) {
container.innerHTML = '<div class="empty-state">Keine Produktionsprofile vorhanden.</div>';
return;
}
container.innerHTML = state.imageFactoryProfiles.map(profile => {
const selected = state.imageFactorySelectedProfiles.has(Number(profile.id));
return `<article class="image-factory-profile-card${selected ? ' is-selected' : ''}" data-image-factory-profile-card="${profile.id}">
<label class="image-factory-profile-select">
<input type="checkbox" data-image-factory-profile="${profile.id}" ${selected ? 'checked' : ''}>
<span>${window.VendooIcons?.svg('check', { size: 13 }) || '✓'}</span>
</label>
<div class="image-factory-profile-icon">${profile.is_builtin ? 'P' : 'E'}</div>
<div class="image-factory-profile-copy"><strong>${escapeHtml(profile.name)}</strong><p>${escapeHtml(profile.description || 'Eigenes Produktionsprofil')}</p><small>${escapeHtml(profileDimensionLabel(profile))}</small></div>
${profile.is_builtin ? '<span class="image-factory-profile-badge">Standard</span>' : `<div class="image-factory-profile-actions"><button type="button" data-image-factory-profile-edit="${profile.id}" title="Profil bearbeiten">Bearbeiten</button><button type="button" data-image-factory-profile-delete="${profile.id}" title="Profil löschen">Löschen</button></div>`}
</article>`;
}).join('');
container.querySelectorAll('[data-image-factory-profile]').forEach(input => input.addEventListener('change', () => {
const id = Number(input.dataset.imageFactoryProfile);
if (input.checked) state.imageFactorySelectedProfiles.add(id); else state.imageFactorySelectedProfiles.delete(id);
input.closest('.image-factory-profile-card')?.classList.toggle('is-selected', input.checked);
updateImageFactorySelection();
}));
container.querySelectorAll('[data-image-factory-profile-edit]').forEach(button => button.addEventListener('click', () => openImageFactoryProfileModal(Number(button.dataset.imageFactoryProfileEdit))));
container.querySelectorAll('[data-image-factory-profile-delete]').forEach(button => button.addEventListener('click', async () => {
const id = Number(button.dataset.imageFactoryProfileDelete);
if (!confirm('Dieses eigene Produktionsprofil löschen? Bestehende Aufträge und Bilder bleiben erhalten.')) return;
try { await api('DELETE', `/api/image-batch/profiles/${id}`); state.imageFactorySelectedProfiles.delete(id); await loadImageFactoryProfiles(); toast('Produktionsprofil gelöscht.'); }
catch (error) { toast(`Profil konnte nicht gelöscht werden: ${error.message}`); }
}));
}
async function loadImageFactorySources({ silent = false } = {}) {
const container = document.getElementById('image-factory-source-grid');
if (!silent && container) container.innerHTML = '<div class="empty-state">Bilder werden geladen …</div>';
try {
const params = new URLSearchParams({ page: '1', page_size: '96', source: document.getElementById('image-factory-source-filter')?.value || 'all', sort: 'newest' });
const search = document.getElementById('image-factory-source-search')?.value.trim();
if (search) params.set('q', search);
const result = await api('GET', `/api/media-library?${params}`);
state.imageFactorySources = result.items || [];
renderImageFactorySources();
} catch (error) {
if (container) container.innerHTML = `<div class="empty-state">Bilder konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
async function loadImageFactoryProfiles() {
try {
const result = await api('GET', '/api/image-batch/profiles');
state.imageFactoryProfiles = result.items || [];
if (!state.imageFactorySelectedProfiles.size && state.imageFactoryProfiles.length) state.imageFactorySelectedProfiles.add(Number(state.imageFactoryProfiles[0].id));
state.imageFactorySelectedProfiles = new Set([...state.imageFactorySelectedProfiles].filter(id => state.imageFactoryProfiles.some(profile => Number(profile.id) === Number(id))));
renderImageFactoryProfiles();
updateImageFactorySelection();
} catch (error) {
const container = document.getElementById('image-factory-profile-grid');
if (container) container.innerHTML = `<div class="empty-state">Profile konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
function renderImageFactorySummary() {
const summary = state.imageFactorySummary || {};
document.querySelectorAll('[data-batch-summary]').forEach(node => { node.textContent = String(summary[node.dataset.batchSummary] || 0); });
}
function batchItemStatusLabel(status) {
return ({ queued: 'Wartend', running: 'Wird erzeugt', completed: 'Fertig', failed: 'Fehler', cancelled: 'Abgebrochen' })[status] || status;
}
function renderImageFactoryJobItems(job) {
if (!job.items) return '<div class="image-factory-job-loading">Details werden geladen …</div>';
return `<div class="image-factory-job-items">${job.items.map(item => `<article class="image-factory-job-item is-${escapeHtml(item.status)}">
<img src="${escapeHtml(photoUrl(item.output_path || item.source_path))}" alt="" loading="lazy">
<div><strong>${escapeHtml(item.profile_name)}</strong><span>${escapeHtml(item.source_path.split('/').pop())}</span>${item.error_message ? `<small>${escapeHtml(item.error_message)}</small>` : ''}</div>
<span class="image-factory-item-status">${escapeHtml(batchItemStatusLabel(item.status))}</span>
${['failed','cancelled'].includes(item.status) ? `<button type="button" class="vd-button compact" data-image-factory-retry-item="${escapeHtml(item.id)}" data-job-id="${escapeHtml(job.id)}">Erneut</button>` : ''}
${item.output_path ? `<a class="vd-button compact" href="${escapeHtml(photoUrl(item.output_path))}" download>Download</a>` : ''}
</article>`).join('')}</div>`;
}
function renderImageFactoryJobs() {
const container = document.getElementById('image-factory-jobs');
if (!container) return;
if (!state.imageFactoryJobs.length) {
container.innerHTML = '<div class="empty-state">Noch keine Produktionsaufträge. Wähle Bilder und Profile und starte die erste Produktion.</div>';
} else {
container.innerHTML = state.imageFactoryJobs.map(job => {
const [label, tone] = imageFactoryStatusMeta(job.status);
const terminal = ['completed','partial','failed','cancelled'].includes(job.status);
const expanded = state.imageFactoryExpanded.has(job.id);
const done = Number(job.completed_items || 0) + Number(job.failed_items || 0) + Number(job.cancelled_items || 0);
const total = Math.max(1, Number(job.total_items || 0));
const percent = Math.min(100, Math.round(done / total * 100));
return `<article class="image-factory-job is-${tone}${expanded ? ' is-expanded' : ''}" data-image-factory-job="${escapeHtml(job.id)}">
<button type="button" class="image-factory-job-head" data-image-factory-job-toggle="${escapeHtml(job.id)}" aria-expanded="${expanded}">
<span class="image-factory-job-chevron"></span><span class="image-factory-job-title"><strong>${escapeHtml(job.name)}</strong><small>${job.source_count} Bilder · ${job.profile_count} Profile · ${formatFluxDate(job.created_at)}</small></span>
<span class="image-factory-job-count">${job.completed_items || 0}/${job.total_items || 0}</span><span class="image-factory-job-status">${label}</span>
</button>
<div class="image-factory-job-progress"><span style="width:${percent}%"></span></div>
<div class="image-factory-job-actions">
${['queued','running'].includes(job.status) ? `<button type="button" class="vd-button compact" data-image-factory-action="pause" data-job-id="${job.id}">Pausieren</button><button type="button" class="vd-button compact danger" data-image-factory-action="cancel" data-job-id="${job.id}">Abbrechen</button>` : ''}
${job.status === 'paused' ? `<button type="button" class="vd-button compact primary" data-image-factory-action="resume" data-job-id="${job.id}">Fortsetzen</button><button type="button" class="vd-button compact danger" data-image-factory-action="cancel" data-job-id="${job.id}">Abbrechen</button>` : ''}
${['failed','partial','cancelled'].includes(job.status) ? `<button type="button" class="vd-button compact" data-image-factory-action="retry" data-job-id="${job.id}">Fehler wiederholen</button>` : ''}
${Number(job.completed_items || 0) > 0 ? `<a class="vd-button compact" href="/api/image-batch/jobs/${encodeURIComponent(job.id)}/archive">Ergebnisse als ZIP</a>` : ''}
${terminal ? `<button type="button" class="vd-button compact danger" data-image-factory-action="delete" data-job-id="${job.id}">Auftrag löschen</button>` : ''}
</div>
<div class="image-factory-job-detail${expanded ? '' : ' hidden'}">${expanded ? renderImageFactoryJobItems(job) : ''}</div>
</article>`;
}).join('');
}
container.querySelectorAll('[data-image-factory-job-toggle]').forEach(button => button.addEventListener('click', async () => {
const id = button.dataset.imageFactoryJobToggle;
if (state.imageFactoryExpanded.has(id)) state.imageFactoryExpanded.delete(id); else state.imageFactoryExpanded.add(id);
const index = state.imageFactoryJobs.findIndex(job => job.id === id);
if (index >= 0 && state.imageFactoryExpanded.has(id) && !state.imageFactoryJobs[index].items) {
try { state.imageFactoryJobs[index] = await api('GET', `/api/image-batch/jobs/${encodeURIComponent(id)}`); } catch (error) { toast(error.message); }
}
renderImageFactoryJobs();
}));
container.querySelectorAll('[data-image-factory-action]').forEach(button => button.addEventListener('click', () => handleImageFactoryJobAction(button.dataset.imageFactoryAction, button.dataset.jobId)));
container.querySelectorAll('[data-image-factory-retry-item]').forEach(button => button.addEventListener('click', async () => {
try { await api('POST', `/api/image-batch/jobs/${encodeURIComponent(button.dataset.jobId)}/items/${encodeURIComponent(button.dataset.imageFactoryRetryItem)}/retry`); toast('Bildposition wurde erneut eingereiht.'); await loadImageFactoryJobs({ silent: true }); }
catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); }
}));
const pagination = state.imageFactoryJobPagination || {};
document.getElementById('image-factory-job-page-info')?.replaceChildren(document.createTextNode(`Seite ${pagination.page || 1} von ${pagination.total_pages || 1}`));
const prev = document.getElementById('image-factory-job-prev'); const next = document.getElementById('image-factory-job-next');
if (prev) prev.disabled = Number(pagination.page || 1) <= 1;
if (next) next.disabled = Number(pagination.page || 1) >= Number(pagination.total_pages || 1);
}
async function loadImageFactoryJobs({ silent = false } = {}) {
const container = document.getElementById('image-factory-jobs');
if (!silent && container && !state.imageFactoryJobs.length) container.innerHTML = '<div class="empty-state">Produktionsaufträge werden geladen …</div>';
try {
const result = await api('GET', `/api/image-batch/jobs?page=${state.imageFactoryJobPage}&page_size=${state.imageFactoryJobPageSize}`);
const previous = new Map(state.imageFactoryJobs.map(job => [job.id, job]));
state.imageFactoryJobs = (result.items || []).map(job => state.imageFactoryExpanded.has(job.id) && previous.get(job.id)?.items ? { ...job, items: previous.get(job.id).items } : job);
const expandedJobs = state.imageFactoryJobs.filter(job => state.imageFactoryExpanded.has(job.id));
if (expandedJobs.length) {
const details = await Promise.all(expandedJobs.map(job => api('GET', `/api/image-batch/jobs/${encodeURIComponent(job.id)}`).catch(() => null)));
const detailMap = new Map(details.filter(Boolean).map(job => [job.id, job]));
state.imageFactoryJobs = state.imageFactoryJobs.map(job => detailMap.get(job.id) || job);
}
state.imageFactoryJobPagination = result.pagination || { page: 1, total_pages: 1, total: 0 };
state.imageFactoryJobPage = Number(state.imageFactoryJobPagination.page || 1);
state.imageFactorySummary = result.summary || null;
renderImageFactorySummary();
renderImageFactoryJobs();
const active = state.imageFactoryJobs.some(job => ['queued','running'].includes(job.status));
if (imageFactoryPollTimer) clearTimeout(imageFactoryPollTimer);
if (active && document.getElementById('image-factory')?.classList.contains('active')) imageFactoryPollTimer = setTimeout(() => loadImageFactoryJobs({ silent: true }), 1600);
} catch (error) {
if (container) container.innerHTML = `<div class="empty-state">Aufträge konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`;
}
}
async function loadImageFactory() {
await Promise.all([loadImageFactoryProfiles(), loadImageFactorySources(), loadImageFactoryJobs()]);
}
async function submitImageFactoryJob() {
const button = document.getElementById('image-factory-submit');
const sourcePaths = [...state.imageFactorySelectedSources];
const profileIds = [...state.imageFactorySelectedProfiles];
if (!sourcePaths.length || !profileIds.length) return toast('Bitte mindestens ein Bild und ein Produktionsprofil auswählen.');
const original = button?.textContent || 'Bildproduktion starten';
if (button) { button.disabled = true; button.textContent = 'Auftrag wird angelegt …'; }
const watermarkEnabled = document.getElementById('image-factory-watermark')?.checked;
const overrides = {
remove_background: document.getElementById('image-factory-remove-bg')?.checked || false,
background_sensitivity: Number(document.getElementById('image-factory-bg-sensitivity')?.value || 45),
watermark: watermarkEnabled ? {
enabled: true,
text: document.getElementById('image-factory-watermark-text')?.value || 'Vendoo',
position: document.getElementById('image-factory-watermark-position')?.value || 'bottom-right',
opacity: Number(document.getElementById('image-factory-watermark-opacity')?.value || 40),
} : { enabled: false },
};
try {
const job = await api('POST', '/api/image-batch/jobs', { name: document.getElementById('image-factory-name')?.value.trim(), source_paths: sourcePaths, profile_ids: profileIds, overrides });
toast(`${job.total_items} Bildausgaben wurden in die Produktion eingereiht.`);
state.imageFactoryJobPage = 1;
state.imageFactoryExpanded.add(job.id);
await loadImageFactoryJobs({ silent: true });
document.getElementById('image-factory-jobs')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} catch (error) { toast(`Bildproduktion konnte nicht gestartet werden: ${error.message}`); }
finally { if (button) { button.disabled = false; button.textContent = original; } updateImageFactorySelection(); }
}
async function handleImageFactoryJobAction(action, jobId) {
if (action === 'delete' && !confirm('Diesen abgeschlossenen Auftrag aus dem Job-Center löschen? Die erzeugten Bilder bleiben erhalten.')) return;
try {
if (action === 'delete') await api('DELETE', `/api/image-batch/jobs/${encodeURIComponent(jobId)}`);
else await api('POST', `/api/image-batch/jobs/${encodeURIComponent(jobId)}/${action}`);
toast(({ pause: 'Auftrag wird pausiert.', resume: 'Auftrag wird fortgesetzt.', cancel: 'Auftrag wird abgebrochen.', retry: 'Fehler wurden erneut eingereiht.', delete: 'Auftrag gelöscht.' })[action] || 'Auftrag aktualisiert.');
await loadImageFactoryJobs({ silent: true });
} catch (error) { toast(`Aktion fehlgeschlagen: ${error.message}`); }
}
function openImageFactoryProfileModal(profileId = null) {
imageFactoryEditingProfileId = profileId;
const profile = state.imageFactoryProfiles.find(item => Number(item.id) === Number(profileId));
const operations = profile?.operations || {};
const resize = operations.resize || {};
document.getElementById('image-factory-profile-title').textContent = profile ? 'Produktionsprofil bearbeiten' : 'Produktionsprofil anlegen';
document.getElementById('image-factory-profile-name').value = profile?.name || '';
document.getElementById('image-factory-profile-description').value = profile?.description || '';
document.getElementById('image-factory-profile-width').value = resize.width || 1600;
document.getElementById('image-factory-profile-height').value = resize.height || 1600;
document.getElementById('image-factory-profile-fit').value = resize.fit || 'contain';
document.getElementById('image-factory-profile-background').value = resize.background || '#ffffff';
document.getElementById('image-factory-profile-format').value = operations.format || 'jpeg';
document.getElementById('image-factory-profile-quality').value = operations.quality || 90;
document.getElementById('image-factory-profile-pattern').value = profile?.filename_pattern || '{name}-{profile}-{index}';
const modal = document.getElementById('image-factory-profile-modal');
modal?.classList.remove('hidden'); modal?.setAttribute('aria-hidden', 'false');
}
function closeImageFactoryProfileModal() {
const modal = document.getElementById('image-factory-profile-modal');
modal?.classList.add('hidden'); modal?.setAttribute('aria-hidden', 'true'); imageFactoryEditingProfileId = null;
}
async function saveImageFactoryProfile() {
const data = {
name: document.getElementById('image-factory-profile-name')?.value.trim(),
description: document.getElementById('image-factory-profile-description')?.value.trim(),
filename_pattern: document.getElementById('image-factory-profile-pattern')?.value.trim(),
operations: {
resize: {
width: Number(document.getElementById('image-factory-profile-width')?.value || 0),
height: Number(document.getElementById('image-factory-profile-height')?.value || 0),
fit: document.getElementById('image-factory-profile-fit')?.value || 'contain',
background: document.getElementById('image-factory-profile-background')?.value || '#ffffff',
},
format: document.getElementById('image-factory-profile-format')?.value || 'jpeg',
quality: Number(document.getElementById('image-factory-profile-quality')?.value || 90),
sharpness: 12,
},
};
if (!data.name) return toast('Bitte einen Namen für das Produktionsprofil eingeben.');
const button = document.getElementById('image-factory-profile-save');
if (button) button.disabled = true;
try {
const profile = imageFactoryEditingProfileId
? await api('PATCH', `/api/image-batch/profiles/${imageFactoryEditingProfileId}`, data)
: await api('POST', '/api/image-batch/profiles', data);
state.imageFactorySelectedProfiles.add(Number(profile.id));
closeImageFactoryProfileModal(); await loadImageFactoryProfiles(); toast('Produktionsprofil gespeichert.');
} catch (error) { toast(`Profil konnte nicht gespeichert werden: ${error.message}`); }
finally { if (button) button.disabled = false; }
}
function setupImageFactory() {
document.getElementById('image-factory-refresh')?.addEventListener('click', loadImageFactory);
document.getElementById('image-factory-jobs-refresh')?.addEventListener('click', () => loadImageFactoryJobs());
document.getElementById('image-factory-start')?.addEventListener('click', () => document.getElementById('image-factory-source-grid')?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
document.getElementById('image-factory-submit')?.addEventListener('click', submitImageFactoryJob);
document.getElementById('image-factory-source-filter')?.addEventListener('change', () => loadImageFactorySources());
document.getElementById('image-factory-source-search')?.addEventListener('input', debounce(() => loadImageFactorySources({ silent: true }), 350));
document.getElementById('image-factory-select-visible')?.addEventListener('click', () => {
const visible = state.imageFactorySources.map(item => item.image_path);
const allSelected = visible.length && visible.every(path => state.imageFactorySelectedSources.has(path));
visible.forEach(path => allSelected ? state.imageFactorySelectedSources.delete(path) : state.imageFactorySelectedSources.add(path));
renderImageFactorySources(); updateImageFactorySelection();
});
document.getElementById('image-factory-clear-sources')?.addEventListener('click', () => { state.imageFactorySelectedSources.clear(); renderImageFactorySources(); updateImageFactorySelection(); });
document.getElementById('image-factory-bg-sensitivity')?.addEventListener('input', event => { document.getElementById('image-factory-bg-value').textContent = event.target.value; });
document.getElementById('image-factory-watermark-opacity')?.addEventListener('input', event => { document.getElementById('image-factory-watermark-value').textContent = `${event.target.value} %`; });
document.getElementById('image-factory-new-profile')?.addEventListener('click', () => openImageFactoryProfileModal());
document.getElementById('image-factory-profile-close')?.addEventListener('click', closeImageFactoryProfileModal);
document.getElementById('image-factory-profile-cancel')?.addEventListener('click', closeImageFactoryProfileModal);
document.getElementById('image-factory-profile-save')?.addEventListener('click', saveImageFactoryProfile);
document.getElementById('image-factory-profile-modal')?.addEventListener('click', event => { if (event.target.id === 'image-factory-profile-modal') closeImageFactoryProfileModal(); });
document.getElementById('image-factory-job-page-size')?.addEventListener('change', event => { state.imageFactoryJobPageSize = Number(event.target.value || 10); state.imageFactoryJobPage = 1; loadImageFactoryJobs(); });
document.getElementById('image-factory-job-prev')?.addEventListener('click', () => { if (state.imageFactoryJobPage > 1) { state.imageFactoryJobPage -= 1; loadImageFactoryJobs(); } });
document.getElementById('image-factory-job-next')?.addEventListener('click', () => { if (state.imageFactoryJobPage < Number(state.imageFactoryJobPagination.total_pages || 1)) { state.imageFactoryJobPage += 1; loadImageFactoryJobs(); } });
document.getElementById('media-send-to-factory')?.addEventListener('click', () => {
for (const imagePath of state.mediaSelected) state.imageFactorySelectedSources.add(imagePath);
switchToTab('image-factory');
toast(`${state.mediaSelected.size} Galeriebild(er) für die Bildproduktion übernommen.`);
});
}
// --- Article Quality Center ---
function qualityTone(report) {
if (report.blockers > 0) return 'blocked';
if (report.ready) return 'ready';
return 'review';
}
function qualityStatusLabel(report) {
if (report.blockers > 0) return `${report.blockers} Blocker`;
if (report.ready) return 'Bereit';
return 'Prüfen';
}
function qualityIssueIcon(severity) {
const names = { error: 'x', warning: 'triangle-alert', recommendation: 'sparkles' };
return window.VendooIcons?.svg(names[severity] || 'circle-help', { size: 18, strokeWidth: 2 }) || (severity === 'error' ? '!' : '·');
}
function renderQualityList() {
const container = document.getElementById('quality-list');
if (!container) return;
if (!state.qualityItems.length) {
container.innerHTML = '<div class="empty-state"><p>Keine Artikel entsprechen den gewählten Filtern.</p></div>';
return;
}
container.innerHTML = state.qualityItems.map(report => {
const item = report.listing || {};
const active = Number(state.qualityCurrent?.listing_id) === Number(report.listing_id);
const tone = qualityTone(report);
return `<button type="button" class="quality-list-item is-${tone}${active ? ' is-active' : ''}" data-quality-listing-id="${report.listing_id}">
<span class="quality-list-thumb">${item.photo ? `<img src="${escapeHtml(photoUrl(item.photo))}" alt="">` : '<span>□</span>'}</span>
<span class="quality-list-copy"><strong>${escapeHtml(item.title || 'Unbenannter Artikel')}</strong><small>${escapeHtml(item.sku || 'Ohne SKU')} · ${escapeHtml(platformLabel(item.platform))}</small><span>${report.issues?.[0] ? escapeHtml(report.issues[0].title) : 'Keine offenen Hinweise'}</span></span>
<span class="quality-list-score is-${String(report.grade || 'E').toLowerCase()}"><b>${report.score}</b><em>${escapeHtml(report.grade || 'E')}</em></span>
<span class="quality-list-state">${escapeHtml(qualityStatusLabel(report))}</span>
</button>`;
}).join('');
container.querySelectorAll('[data-quality-listing-id]').forEach(button => button.addEventListener('click', () => loadQualityDetail(button.dataset.qualityListingId)));
}
function renderQualitySummary() {
const summary = state.qualitySummary || {};
const values = {
'quality-summary-average': summary.average_score ?? 0,
'quality-summary-ready': summary.ready ?? 0,
'quality-summary-blocked': summary.blocked ?? 0,
'quality-summary-total': summary.total ?? 0,
};
Object.entries(values).forEach(([id, value]) => { const el = document.getElementById(id); if (el) el.textContent = value; });
}
function renderQualityPagination() {
const pagination = state.qualityPagination || {};
const page = Number(pagination.page || 1); const totalPages = Number(pagination.total_pages || 1); const total = Number(pagination.total || 0);
const start = total ? (page - 1) * Number(pagination.page_size || state.qualityPageSize) + 1 : 0;
const end = Math.min(total, page * Number(pagination.page_size || state.qualityPageSize));
const info = document.getElementById('quality-page-info'); if (info) info.textContent = `Seite ${page} von ${totalPages}`;
const range = document.getElementById('quality-list-range'); if (range) range.textContent = total ? `${start}${end} von ${total}` : '0 Artikel';
const prev = document.getElementById('quality-prev'); const next = document.getElementById('quality-next');
if (prev) prev.disabled = page <= 1; if (next) next.disabled = page >= totalPages;
}
async function loadQualityCenter({ silent = false } = {}) {
const container = document.getElementById('quality-list');
if (!silent && container) container.innerHTML = '<div class="quality-loading"><span></span><span></span><span></span><p>Artikelqualität wird geladen …</p></div>';
const params = new URLSearchParams({
q: document.getElementById('quality-search')?.value.trim() || '',
status: document.getElementById('quality-status')?.value || 'all',
grade: document.getElementById('quality-grade')?.value || 'all',
page: String(state.qualityPage),
page_size: String(state.qualityPageSize),
});
try {
const result = await api('GET', `/api/quality-center?${params}`);
state.qualityItems = result.items || [];
state.qualityPagination = result.pagination || { page: 1, total_pages: 1, total: 0 };
state.qualitySummary = result.summary || {};
renderQualitySummary(); renderQualityPagination(); renderQualityList();
if (!state.qualityCurrent && state.qualityItems[0]) loadQualityDetail(state.qualityItems[0].listing_id, { quiet: true });
} catch (error) {
if (container) container.innerHTML = `<div class="empty-state"><p>Quality Center konnte nicht geladen werden: ${escapeHtml(error.message)}</p></div>`;
}
}
function renderQualityIssues(report) {
const container = document.getElementById('quality-issues');
if (!container) return;
const filter = state.qualityIssueFilter;
const issues = (report.issues || []).filter(item => filter === 'all' || item.severity === filter);
if (!issues.length) {
container.innerHTML = '<div class="quality-all-clear"><strong>Keine Punkte in diesem Bereich</strong><span>Der Artikel erfüllt die aktuell geprüften Qualitätsregeln.</span></div>';
return;
}
container.innerHTML = issues.map(item => `<article class="quality-issue is-${escapeHtml(item.severity)}">
<span class="quality-issue-icon">${qualityIssueIcon(item.severity)}</span>
<div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail || '')}</p><small>${escapeHtml(item.category || '')}${item.field ? ` · Feld: ${escapeHtml(item.field)}` : ''}</small></div>
<span class="quality-issue-points">${Number(item.points || 0)}</span>
</article>`).join('');
}
function renderQualityDetail(report) {
state.qualityCurrent = report;
document.getElementById('quality-detail-empty')?.classList.add('hidden');
document.getElementById('quality-detail-content')?.classList.remove('hidden');
const title = document.getElementById('quality-detail-title'); if (title) title.textContent = report.listing?.title || 'Unbenannter Artikel';
const platform = document.getElementById('quality-detail-platform'); if (platform) platform.textContent = `${report.platform?.name || platformLabel(report.listing?.platform)} · Note ${report.grade}`;
const meta = document.getElementById('quality-detail-meta'); if (meta) meta.textContent = `${report.listing?.sku || 'Ohne SKU'} · ${report.facts?.photo_count || 0} Bilder · ${report.facts?.title_length || 0} Titelzeichen`;
const score = document.getElementById('quality-detail-score'); if (score) score.textContent = report.score;
const ring = document.getElementById('quality-score-ring'); if (ring) { ring.dataset.grade = report.grade || 'E'; ring.style.setProperty('--quality-score', `${report.score * 3.6}deg`); }
const readiness = document.getElementById('quality-readiness');
if (readiness) readiness.innerHTML = `<strong>${report.ready ? 'Bereit zur Veröffentlichung' : report.blockers ? 'Veröffentlichung blockiert' : 'Überarbeitung empfohlen'}</strong><span>${report.blockers || 0} Blocker · ${report.warnings || 0} Warnungen · ${report.recommendations || 0} Empfehlungen</span>`;
const finance = report.financials || {};
const financials = document.getElementById('quality-financials');
if (financials) financials.innerHTML = `<div><span>Preis</span><strong>${formatCurrency(finance.price || report.listing?.price || 0)}</strong></div><div><span>Gebühren</span><strong>${formatCurrency(finance.fees || 0)}</strong></div><div><span>Geschätzter Erlös</span><strong>${formatCurrency(finance.estimated_net || 0)}</strong></div>`;
renderQualityIssues(report);
const duplicates = document.getElementById('quality-duplicates');
if (duplicates) duplicates.innerHTML = report.duplicates?.length ? report.duplicates.map(item => `<button type="button" data-quality-duplicate-id="${item.id}"><strong>${escapeHtml(item.title || 'Artikel')}</strong><span>${escapeHtml(item.sku || 'Ohne SKU')} · ${item.same_sku ? 'gleiche SKU' : `${item.similarity}% ähnlich`}</span></button>`).join('') : '<p class="quality-empty-note">Keine auffälligen Duplikate gefunden.</p>';
duplicates?.querySelectorAll('[data-quality-duplicate-id]').forEach(button => button.addEventListener('click', () => showDetail(button.dataset.qualityDuplicateId)));
const images = document.getElementById('quality-images');
if (images) images.innerHTML = report.images?.length ? report.images.map(item => `<article class="quality-image-check"><span>${item.missing || item.unreadable ? '!' : '✓'}</span><div><strong>${escapeHtml(item.path || 'Bild')}</strong><small>${item.width ? `${item.width} × ${item.height} px · ${item.megapixels} MP · ${escapeHtml(item.format || '')}` : item.missing ? 'Datei fehlt' : 'Nicht lesbar'}</small></div></article>`).join('') : '<p class="quality-empty-note">Für eine vollständige Bildprüfung bitte „Neu analysieren“ verwenden.</p>';
const history = document.getElementById('quality-history');
if (history) history.innerHTML = report.history?.length ? report.history.map(item => `<div class="quality-history-item"><span>${new Date(String(item.analyzed_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE')}</span><strong>${item.score}</strong><em>Note ${item.grade}</em></div>`).join('') : '<p class="quality-empty-note">Noch kein gespeicherter Verlauf.</p>';
const delta = document.getElementById('quality-score-delta');
if (delta) { const value = Number(report.comparison?.delta || 0); delta.textContent = value ? `${value > 0 ? '+' : ''}${value} seit letzter Prüfung` : 'Noch kein Vergleich'; delta.dataset.tone = value > 0 ? 'up' : value < 0 ? 'down' : 'neutral'; }
renderQualityList();
}
async function loadQualityDetail(listingId, { quiet = false } = {}) {
const panel = document.getElementById('quality-detail-content');
if (!quiet && panel) panel.classList.add('is-loading');
try {
const report = await api('GET', `/api/quality-center/${listingId}`, null, { timeoutMs: 20000 });
renderQualityDetail(report);
} catch (error) { toast(`Qualitätsprüfung fehlgeschlagen: ${error.message}`); }
finally { panel?.classList.remove('is-loading'); }
}
async function runQualityAnalysis(listingId = state.qualityCurrent?.listing_id) {
if (!listingId) return;
const button = document.getElementById('quality-run-analysis'); const original = button?.textContent;
if (button) { button.disabled = true; button.textContent = 'Analyse läuft …'; }
try {
const report = await api('POST', `/api/quality-center/${listingId}/analyze`, {}, { timeoutMs: 30000 });
renderQualityDetail(report); await loadQualityCenter({ silent: true }); toast(`Qualitätsanalyse abgeschlossen: ${report.score}/100`);
} catch (error) { toast(`Analyse fehlgeschlagen: ${error.message}`); }
finally { if (button) { button.disabled = false; button.textContent = original || 'Neu analysieren'; } }
}
async function analyzeVisibleQualityItems() {
const ids = state.qualityItems.map(item => item.listing_id).slice(0, 25);
if (!ids.length) return toast('Keine sichtbaren Artikel vorhanden.');
const button = document.getElementById('quality-analyze-visible'); const original = button?.textContent;
if (button) button.disabled = true;
let done = 0; let failed = 0;
for (const id of ids) {
if (button) button.textContent = `${done + 1} / ${ids.length} prüfen`;
try { await api('POST', `/api/quality-center/${id}/analyze`, {}, { timeoutMs: 30000 }); done += 1; } catch { failed += 1; }
}
if (button) { button.disabled = false; button.textContent = original || 'Sichtbare prüfen'; }
await loadQualityCenter({ silent: true });
if (state.qualityCurrent?.listing_id) await loadQualityDetail(state.qualityCurrent.listing_id, { quiet: true });
toast(`${done} Artikel geprüft${failed ? ` · ${failed} fehlgeschlagen` : ''}.`);
}
function openQualityAiModal() {
if (!state.qualityCurrent?.listing_id) return;
state.qualityAiDraft = null;
const modal = document.getElementById('quality-ai-modal'); modal?.classList.remove('hidden');
document.getElementById('quality-ai-compare')?.classList.add('hidden');
document.getElementById('quality-ai-apply')?.classList.add('hidden');
const status = document.getElementById('quality-ai-status'); if (status) status.textContent = 'Wähle die Felder und erstelle einen Vorschlag.';
const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = '';
}
function closeQualityAiModal() { document.getElementById('quality-ai-modal')?.classList.add('hidden'); state.qualityAiDraft = null; }
async function generateQualityAiSuggestion() {
const fields = [['title','quality-ai-field-title'],['description','quality-ai-field-description'],['tags','quality-ai-field-tags']].filter(([, id]) => document.getElementById(id)?.checked).map(([field]) => field);
if (!fields.length) return toast('Bitte mindestens ein Feld auswählen.');
const button = document.getElementById('quality-ai-generate'); const status = document.getElementById('quality-ai-status');
if (button) button.disabled = true; if (status) status.textContent = 'Der aktive AI-Provider erstellt sichere Vorschläge …';
try {
const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/ai-suggestions`, { fields, mode: document.getElementById('quality-ai-mode')?.value || 'balanced' }, { timeoutMs: 60000 });
state.qualityAiDraft = result;
const original = result.original || {}; const suggestions = result.suggestions || {};
document.getElementById('quality-ai-original-title').value = original.title || '';
document.getElementById('quality-ai-original-description').value = original.description || '';
document.getElementById('quality-ai-original-tags').value = (original.tags || []).join(', ');
document.getElementById('quality-ai-suggested-title').value = suggestions.title ?? original.title ?? '';
document.getElementById('quality-ai-suggested-description').value = suggestions.description ?? original.description ?? '';
document.getElementById('quality-ai-suggested-tags').value = (suggestions.tags ?? original.tags ?? []).join(', ');
document.getElementById('quality-ai-compare')?.classList.remove('hidden'); document.getElementById('quality-ai-apply')?.classList.remove('hidden');
const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = (suggestions.reasons || []).map(reason => `<span>${escapeHtml(reason)}</span>`).join('');
if (status) status.textContent = `Vorschlag über ${result.provider} erstellt. Prüfe und bearbeite ihn vor dem Übernehmen.`;
} catch (error) { if (status) status.textContent = `AI-Vorschlag fehlgeschlagen: ${error.message}`; }
finally { if (button) button.disabled = false; }
}
async function applyQualityAiSuggestion() {
if (!state.qualityAiDraft || !state.qualityCurrent?.listing_id) return;
const payload = {};
if (document.getElementById('quality-ai-field-title')?.checked) payload.title = document.getElementById('quality-ai-suggested-title').value.trim();
if (document.getElementById('quality-ai-field-description')?.checked) payload.description = document.getElementById('quality-ai-suggested-description').value.trim();
if (document.getElementById('quality-ai-field-tags')?.checked) payload.tags = document.getElementById('quality-ai-suggested-tags').value.split(',').map(value => value.trim()).filter(Boolean);
const button = document.getElementById('quality-ai-apply'); if (button) button.disabled = true;
try {
const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/apply-suggestions`, payload, { timeoutMs: 30000 });
closeQualityAiModal(); renderQualityDetail(result.report); await loadQualityCenter({ silent: true }); toast('Bestätigte Verbesserungen wurden gespeichert.');
} catch (error) { toast(`Verbesserungen konnten nicht gespeichert werden: ${error.message}`); }
finally { if (button) button.disabled = false; }
}
function setupQualityCenter() {
document.getElementById('quality-refresh')?.addEventListener('click', () => loadQualityCenter());
document.getElementById('quality-analyze-visible')?.addEventListener('click', analyzeVisibleQualityItems);
document.getElementById('quality-search')?.addEventListener('input', debounce(() => { state.qualityPage = 1; loadQualityCenter({ silent: true }); }, 300));
document.getElementById('quality-status')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); });
document.getElementById('quality-grade')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); });
document.getElementById('quality-page-size')?.addEventListener('change', event => { state.qualityPageSize = Number(event.target.value || 10); state.qualityPage = 1; loadQualityCenter(); });
document.getElementById('quality-prev')?.addEventListener('click', () => { if (state.qualityPage > 1) { state.qualityPage -= 1; loadQualityCenter(); } });
document.getElementById('quality-next')?.addEventListener('click', () => { if (state.qualityPage < Number(state.qualityPagination.total_pages || 1)) { state.qualityPage += 1; loadQualityCenter(); } });
document.getElementById('quality-run-analysis')?.addEventListener('click', () => runQualityAnalysis());
document.getElementById('quality-open-article')?.addEventListener('click', () => state.qualityCurrent?.listing_id && showDetail(state.qualityCurrent.listing_id));
document.getElementById('quality-open-ai')?.addEventListener('click', openQualityAiModal);
document.querySelectorAll('[data-quality-issue-filter]').forEach(button => button.addEventListener('click', () => {
state.qualityIssueFilter = button.dataset.qualityIssueFilter || 'all';
document.querySelectorAll('[data-quality-issue-filter]').forEach(item => item.classList.toggle('active', item === button));
if (state.qualityCurrent) renderQualityIssues(state.qualityCurrent);
}));
document.getElementById('quality-ai-close')?.addEventListener('click', closeQualityAiModal);
document.getElementById('quality-ai-cancel')?.addEventListener('click', closeQualityAiModal);
document.getElementById('quality-ai-generate')?.addEventListener('click', generateQualityAiSuggestion);
document.getElementById('quality-ai-apply')?.addEventListener('click', applyQualityAiSuggestion);
document.getElementById('quality-ai-modal')?.addEventListener('click', event => { if (event.target.id === 'quality-ai-modal') closeQualityAiModal(); });
}
// --- System Diagnostics ---
function diagnosticTone(status) {
return status === 'ok' ? 'ok' : status === 'error' ? 'error' : status === 'warning' ? 'warning' : 'optional';
}
function diagnosticIcon(status) {
const name = status === 'ok' ? 'shield-check' : status === 'error' ? 'x' : 'circle-help';
return window.VendooIcons?.svg(name, { size: 18, strokeWidth: 2 }) || '';
}
function renderSystemDiagnostics(report) {
const summary = document.getElementById('system-diagnostics-summary');
const grid = document.getElementById('system-diagnostics-grid');
const copy = document.getElementById('system-diagnostics-copy');
if (!summary || !grid) return;
const errors = report.checks?.filter(item => item.status === 'error').length || 0;
const warnings = report.checks?.filter(item => ['warning', 'optional'].includes(item.status)).length || 0;
summary.className = `diagnostics-summary ${errors ? 'is-error' : warnings ? 'is-warning' : 'is-ok'}`;
summary.innerHTML = `<strong>${errors ? `${errors} kritische Prüfung(en) fehlgeschlagen` : 'Vendoo-Kernsystem ist betriebsbereit'}</strong><span>${warnings ? `${warnings} Hinweis(e) · ` : ''}${report.checks?.length || 0} Prüfungen in ${report.duration_ms || 0} ms · Build ${escapeHtml(report.version || VENDOO_UI_BUILD)}</span>`;
grid.innerHTML = (report.checks || []).map(check => `
<article class="diagnostic-card is-${diagnosticTone(check.status)}">
<span class="diagnostic-card-icon">${diagnosticIcon(check.status)}</span>
<div><strong>${escapeHtml(check.label)}</strong><p>${escapeHtml(check.detail || '')}</p></div>
<span class="diagnostic-card-status">${check.status === 'ok' ? 'OK' : check.status === 'error' ? 'Fehler' : check.status === 'warning' ? 'Hinweis' : 'Optional'}</span>
</article>`).join('');
if (copy) copy.disabled = false;
}
async function runSystemDiagnostics() {
const button = document.getElementById('system-diagnostics-btn');
const summary = document.getElementById('system-diagnostics-summary');
const grid = document.getElementById('system-diagnostics-grid');
const original = button?.textContent || 'Systemprüfung starten';
if (button) { button.disabled = true; button.textContent = 'Prüfung läuft …'; }
if (summary) { summary.className = 'diagnostics-summary is-running'; summary.textContent = 'Datenbank, Dateisystem, UI, Extensions und optionale Dienste werden geprüft …'; }
if (grid) grid.innerHTML = '<div class="diagnostics-loading"><span></span><span></span><span></span></div>';
try {
const report = await api('GET', '/api/admin/diagnostics', null, { retry: false, timeoutMs: 15000 });
clientDiagnostics.lastSystemReport = report;
renderSystemDiagnostics(report);
toast(report.ok ? 'Systemprüfung erfolgreich.' : 'Systemprüfung hat kritische Fehler gefunden.');
} catch (error) {
if (summary) { summary.className = 'diagnostics-summary is-error'; summary.textContent = `Systemprüfung fehlgeschlagen: ${error.message}`; }
if (grid) grid.innerHTML = '';
toast(`Systemprüfung fehlgeschlagen: ${error.message}`);
} finally {
if (button) { button.disabled = false; button.textContent = original; }
}
}
async function copySystemDiagnostics() {
const report = clientDiagnostics.lastSystemReport;
if (!report) return toast('Bitte zuerst eine Systemprüfung starten.');
const text = [
`Vendoo Diagnose ${report.version || VENDOO_UI_BUILD}`,
`Erstellt: ${report.generated_at || new Date().toISOString()}`,
`Runtime: ${report.runtime?.node || '?'} · ${report.runtime?.platform || '?'} ${report.runtime?.arch || ''}`,
'',
...(report.checks || []).map(check => `[${String(check.status).toUpperCase()}] ${check.label}: ${check.detail}`),
'',
`Client-Fehler (letzte ${clientDiagnostics.incidents.length}):`,
...clientDiagnostics.incidents.slice(0, 10).map(item => `${item.at} · ${item.type} · ${item.view} · ${item.message} · ${item.requestId || ''}`),
].join('\n');
try {
await navigator.clipboard.writeText(text);
toast('Diagnose in die Zwischenablage kopiert.');
} catch {
const area = document.createElement('textarea');
area.value = text; document.body.append(area); area.select(); document.execCommand('copy'); area.remove();
toast('Diagnose kopiert.');
}
}
// --- System Info ---
async function loadSystemInfo() {
try {
const sys = await api('GET', '/api/admin/system');
document.getElementById('system-info-cards').innerHTML = `
<div class="system-info-card">
<div class="sys-label">Server Uptime</div>
<div class="sys-value">${sys.uptime}</div>
</div>
<div class="system-info-card">
<div class="sys-label">Benutzer</div>
<div class="sys-value accent">${sys.total_users}</div>
</div>
<div class="system-info-card">
<div class="sys-label">Artikel</div>
<div class="sys-value accent">${sys.total_listings}</div>
</div>
<div class="system-info-card">
<div class="sys-label">Aktive Sessions</div>
<div class="sys-value">${sys.active_sessions}</div>
</div>
<div class="system-info-card">
<div class="sys-label">Datenbankgröße</div>
<div class="sys-value">${sys.db_size}</div>
</div>
<div class="system-info-card">
<div class="sys-label">Node.js</div>
<div class="sys-value" style="font-size:16px">${sys.node_version}</div>
</div>
<div class="system-info-card">
<div class="sys-label">Speicher (RAM)</div>
<div class="sys-value">${sys.memory_mb} MB</div>
</div>
<div class="system-info-card">
<div class="sys-label">Plattform</div>
<div class="sys-value" style="font-size:16px">${sys.platform}</div>
</div>
`;
} catch (err) { toast('Fehler: ' + err.message); }
}
async function loadMySessions() {
try {
const sessions = await api('GET', '/api/my/sessions');
const container = document.getElementById('my-sessions-list');
container.innerHTML = sessions.length ? sessions.map(s => {
const ua = parseUA(s.user_agent);
return `<div class="session-row">
<div class="session-info">
<div class="session-ua">${ua}</div>
<div class="session-ip">IP: ${s.ip || 'unbekannt'}</div>
</div>
<div class="session-meta">${timeAgo(s.created_at)}</div>
<button class="small-btn" data-end-my-session-id="${s.id}" style="color:var(--danger)">Beenden</button>
</div>`;
}).join('') : '<div class="empty-state">Keine aktiven Sessions</div>';
document.getElementById('my-sessions-modal').classList.remove('hidden');
} catch (err) { toast('Fehler: ' + err.message); }
}
async function endMySession(sessionId) {
try {
await api('DELETE', `/api/my/sessions/${sessionId}`);
toast('Session beendet');
loadMySessions();
} catch (err) { toast('Fehler: ' + err.message); }
}
function parseUA(ua) {
if (!ua) return 'Unbekannt';
if (ua.includes('Chrome')) return 'Chrome';
if (ua.includes('Firefox')) return 'Firefox';
if (ua.includes('Safari')) return 'Safari';
if (ua.includes('Edge')) return 'Edge';
return ua.substring(0, 40);
}
init().catch(error => {
recordClientIncident('startup', error?.message || String(error));
setConnectionState(false, `Vendoo konnte nicht vollständig gestartet werden: ${error?.message || 'Unbekannter Fehler'}`);
console.error('[Vendoo] Initialisierung fehlgeschlagen', error);
});
// --- Vendoo 1.29.0 Publishing Hardening UI ---
publishState.jobsData = { items: [], pagination: { page: 1, page_size: 10, total: 0, total_pages: 1 }, summary: {} };
publishState.jobPage = 1;
publishState.jobPageSize = 10;
publishState.jobStatus = '';
publishState.jobPlatform = '';
publishState.jobSearch = '';
publishState.jobSelected = new Set();
publishState.publishEvents = [];
publishState.capabilities = null;
async function loadPublishingQueueData({ render = true } = {}) {
const params = new URLSearchParams({ page: String(publishState.jobPage), page_size: String(publishState.jobPageSize) });
if (publishState.jobStatus) params.set('status', publishState.jobStatus);
if (publishState.jobPlatform) params.set('platform', publishState.jobPlatform);
if (publishState.jobSearch) params.set('q', publishState.jobSearch);
const [jobs, events, capabilities] = await Promise.all([
publishSafeGet(`/api/publishing/jobs?${params}`, { items: [], pagination: { page: 1, page_size: publishState.jobPageSize, total: 0, total_pages: 1 }, summary: {} }, 2),
publishSafeGet('/api/publishing/events?limit=120', [], 2),
publishSafeGet('/api/publishing/capabilities', null, 2),
]);
publishState.jobsData = jobs;
publishState.publishEvents = Array.isArray(events) ? events : [];
publishState.capabilities = capabilities;
const visible = new Set((jobs.items || []).map(item => item.id));
publishState.jobSelected = new Set([...publishState.jobSelected].filter(id => visible.has(id)));
if (render) {
renderPublishWorkspace();
renderPublishStatusRail();
}
}
async function loadPublishList() {
await loadPublishListLegacy();
try { await loadPublishingQueueData(); }
catch (error) { console.warn('[Vendoo] Publishing-Queue konnte nicht geladen werden', error); }
}
function setPublishView(view) {
publishState.view = ['smart', 'direct', 'queue', 'errors', 'scheduled'].includes(view) ? view : 'smart';
document.querySelectorAll('[data-publish-view]').forEach(button => {
const active = button.dataset.publishView === publishState.view;
button.classList.toggle('active', active);
button.setAttribute('aria-selected', String(active));
});
renderPublishWorkspace();
}
function renderPublishWorkspace() {
if (publishState.view === 'queue') { renderQueueWorkspace(); return; }
if (publishState.view === 'errors') { renderPublishingErrorsWorkspace(); return; }
if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; }
const listing = selectedPublishListing();
const workspace = document.getElementById('publish-workspace');
if (!workspace) return;
if (!listing) {
workspace.innerHTML = `<div class="publish-workspace-empty"><span>↗</span><h3>Artikel auswählen</h3><p>Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.</p></div>`;
return;
}
if (publishState.view === 'direct') renderDirectPublishWorkspace(listing);
else renderSmartCopyLauncher(listing);
}
function publishingStatusLabel(status) {
return ({ queued: 'Wartend', running: 'In Bearbeitung', awaiting_user: 'Wartet auf Extension', retry_wait: 'Wiederholung geplant', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen', blocked: 'Blockiert', ended: 'Beendet' })[status] || status || 'Unbekannt';
}
function publishingActionLabel(action) {
return ({ publish: 'Veröffentlichen', update: 'Aktualisieren', end: 'Beenden', sync: 'Status abgleichen' })[action] || action;
}
function publishingModeLabel(mode) { return mode === 'api' ? 'Offizielle API' : 'Browser-Extension'; }
function publishingCan(job, action) {
return Boolean(publishState.capabilities?.platforms?.[job.platform]?.[action]);
}
function publishingDate(value) {
if (!value) return '—';
const date = new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z'));
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
}
function renderQueueWorkspace() {
const workspace = document.getElementById('publish-workspace');
const data = publishState.jobsData || { items: [], pagination: {}, summary: {} };
const items = data.items || [];
const page = data.pagination?.page || 1;
const pages = data.pagination?.total_pages || 1;
const total = data.pagination?.total || 0;
const selectedCount = publishState.jobSelected.size;
const platformOptions = ['', 'ebay-de', 'etsy', 'vinted', 'ebay-ka'].map(value => `<option value="${value}" ${publishState.jobPlatform === value ? 'selected' : ''}>${value ? escapeHtml(platformLabel(value)) : 'Alle Plattformen'}</option>`).join('');
const statusOptions = ['', 'queued', 'running', 'awaiting_user', 'retry_wait', 'published', 'drafted', 'failed', 'cancelled', 'ended'].map(value => `<option value="${value}" ${publishState.jobStatus === value ? 'selected' : ''}>${value ? escapeHtml(publishingStatusLabel(value)) : 'Alle Status'}</option>`).join('');
workspace.innerHTML = `<header class="publish-operation-header publishing-hardened-header">
<div><span class="publish-panel-kicker">Zuverlässige Verarbeitung</span><h3>Publishing-Queue</h3><p>${total} Aufträge · Schutz vor Doppelveröffentlichung · automatische Wiederholungen</p></div>
<div><button class="vd-button" type="button" data-publishing-refresh>Aktualisieren</button><button class="vd-button primary" type="button" data-publishing-add ${publishState.selected.size || publishState.selectedId ? '' : 'disabled'}>Auswahl einreihen</button></div>
</header>
<div class="publishing-toolbar">
<label class="publish-search-field"><span aria-hidden="true">⌕</span><input type="search" data-publishing-search value="${escapeHtml(publishState.jobSearch)}" placeholder="Titel, SKU, externe ID oder Fehler …"></label>
<select data-publishing-platform>${platformOptions}</select>
<select data-publishing-status>${statusOptions}</select>
<select data-publishing-size><option value="5" ${publishState.jobPageSize === 5 ? 'selected' : ''}>5 / Seite</option><option value="10" ${publishState.jobPageSize === 10 ? 'selected' : ''}>10 / Seite</option><option value="15" ${publishState.jobPageSize === 15 ? 'selected' : ''}>15 / Seite</option></select>
</div>
<div class="publishing-selection-bar"><label><input type="checkbox" data-publishing-select-page ${items.length && items.every(item => publishState.jobSelected.has(item.id)) ? 'checked' : ''}> Seite auswählen</label><span>${selectedCount} ausgewählt</span><button class="vd-button compact danger" type="button" data-publishing-delete-selected ${selectedCount ? '' : 'disabled'}>Ausgewählte löschen</button></div>
<div class="publish-operation-list publishing-job-list">${items.length ? items.map(renderPublishingJobRow).join('') : '<div class="publish-workspace-empty compact"><span>✓</span><h3>Keine Aufträge</h3><p>Wähle Artikel aus und füge sie zur Publishing-Queue hinzu.</p></div>'}</div>
<footer class="publishing-pagination"><span>Seite ${page} von ${pages}</span><div><button class="vd-button compact" data-publishing-prev ${page <= 1 ? 'disabled' : ''}>Zurück</button><button class="vd-button compact" data-publishing-next ${page >= pages ? 'disabled' : ''}>Weiter</button></div></footer>`;
workspace.querySelector('[data-publishing-refresh]')?.addEventListener('click', () => loadPublishingQueueData());
workspace.querySelector('[data-publishing-add]')?.addEventListener('click', batchEbayAdd);
workspace.querySelector('[data-publishing-search]')?.addEventListener('input', debounce(event => { publishState.jobSearch = event.target.value.trim(); publishState.jobPage = 1; loadPublishingQueueData(); }, 280));
workspace.querySelector('[data-publishing-platform]')?.addEventListener('change', event => { publishState.jobPlatform = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); });
workspace.querySelector('[data-publishing-status]')?.addEventListener('change', event => { publishState.jobStatus = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); });
workspace.querySelector('[data-publishing-size]')?.addEventListener('change', event => { publishState.jobPageSize = Number(event.target.value); publishState.jobPage = 1; loadPublishingQueueData(); });
workspace.querySelector('[data-publishing-prev]')?.addEventListener('click', () => { publishState.jobPage = Math.max(1, page - 1); loadPublishingQueueData(); });
workspace.querySelector('[data-publishing-next]')?.addEventListener('click', () => { publishState.jobPage = Math.min(pages, page + 1); loadPublishingQueueData(); });
workspace.querySelector('[data-publishing-select-page]')?.addEventListener('change', event => {
for (const item of items) event.target.checked ? publishState.jobSelected.add(item.id) : publishState.jobSelected.delete(item.id);
renderQueueWorkspace();
});
workspace.querySelectorAll('[data-publishing-check]').forEach(input => input.addEventListener('change', event => {
event.target.checked ? publishState.jobSelected.add(event.target.dataset.publishingCheck) : publishState.jobSelected.delete(event.target.dataset.publishingCheck);
renderQueueWorkspace();
}));
workspace.querySelector('[data-publishing-delete-selected]')?.addEventListener('click', deleteSelectedPublishingJobs);
workspace.querySelectorAll('[data-publishing-action]').forEach(button => button.addEventListener('click', () => runPublishingJobAction(button.dataset.publishingId, button.dataset.publishingAction)));
}
function renderPublishingJobRow(job) {
const canCancel = ['queued', 'running', 'awaiting_user', 'retry_wait'].includes(job.status);
const canRetry = ['failed', 'blocked', 'cancelled'].includes(job.status);
const canDelete = ['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked'].includes(job.status);
const isLive = job.status === 'published';
return `<article class="publish-operation-row publishing-job-row status-${escapeHtml(job.status)}">
<label class="publishing-job-check"><input type="checkbox" data-publishing-check="${job.id}" ${publishState.jobSelected.has(job.id) ? 'checked' : ''}></label>
<div class="publish-operation-product"><span class="publish-operation-mark">${escapeHtml(String(job.sku || 'VD').slice(-2))}</span><div><strong>${escapeHtml(job.title || 'Ohne Titel')}</strong><small>${escapeHtml(job.sku || '')} · ${escapeHtml(platformLabel(job.platform))}</small></div></div>
<div class="publish-operation-meta"><span>Aktion</span><b>${escapeHtml(publishingActionLabel(job.action))}</b><small>${escapeHtml(publishingModeLabel(job.mode))}</small></div>
<div class="publish-operation-meta"><span>Status</span><b class="operation-status status-${escapeHtml(job.status)}">${escapeHtml(publishingStatusLabel(job.status))}</b><small>${escapeHtml(publishingDate(job.updated_at))}</small></div>
<div class="publish-operation-meta"><span>Versuche</span><b>${Number(job.retries)}/${Number(job.max_retries)}</b><small>${job.next_attempt_at ? `Nächster: ${escapeHtml(publishingDate(job.next_attempt_at))}` : ''}</small></div>
<div class="publish-operation-error">${job.error_message ? `<span title="${escapeHtml(job.error_message)}">${escapeHtml(job.error_message)}</span>` : job.external_id ? `<span>Externe ID: ${escapeHtml(job.external_id)}</span>` : '<span>Keine Fehler</span>'}</div>
<div class="publish-operation-actions publishing-job-actions">
${job.external_url ? `<a class="icon-button" href="${escapeHtml(job.external_url)}" target="_blank" rel="noopener" title="Extern öffnen">↗</a>` : ''}
${isLive && publishingCan(job, 'sync') ? `<button class="icon-button" data-publishing-action="sync" data-publishing-id="${job.id}" title="Status abgleichen">⟳</button>` : ''}
${isLive && publishingCan(job, 'update') ? `<button class="icon-button" data-publishing-action="update" data-publishing-id="${job.id}" title="Angebot aktualisieren">✎</button>` : ''}
${isLive && publishingCan(job, 'end') ? `<button class="icon-button danger" data-publishing-action="end" data-publishing-id="${job.id}" title="Angebot beenden">■</button>` : ''}
${canRetry ? `<button class="icon-button" data-publishing-action="retry" data-publishing-id="${job.id}" title="Wiederholen">↻</button>` : ''}
${canCancel ? `<button class="icon-button danger" data-publishing-action="cancel" data-publishing-id="${job.id}" title="Abbrechen">×</button>` : ''}
${canDelete ? `<button class="icon-button danger" data-publishing-action="delete" data-publishing-id="${job.id}" title="Protokoll löschen">⌫</button>` : ''}
</div>
</article>`;
}
async function runPublishingJobAction(id, action) {
try {
if (action === 'retry') await api('POST', `/api/publishing/jobs/${id}/retry`);
else if (action === 'cancel') await api('POST', `/api/publishing/jobs/${id}/cancel`);
else if (action === 'delete') {
if (!confirm('Diesen abgeschlossenen Publishing-Auftrag aus dem Protokoll löschen? Das externe Angebot bleibt unberührt.')) return;
await api('DELETE', '/api/publishing/jobs', { ids: [id] });
} else if (['sync', 'update', 'end'].includes(action)) {
if (action === 'end' && !confirm('Das externe Angebot wirklich beenden?')) return;
await api('POST', `/api/publishing/jobs/${id}/action`, { action });
}
toast(action === 'sync' ? 'Statusabgleich eingereiht' : action === 'update' ? 'Aktualisierung eingereiht' : action === 'end' ? 'Beenden eingereiht' : 'Auftrag aktualisiert');
await loadPublishingQueueData();
} catch (error) { toast(`Publishing-Aktion fehlgeschlagen: ${error.message}`); }
}
async function deleteSelectedPublishingJobs() {
const ids = [...publishState.jobSelected];
if (!ids.length || !confirm(`${ids.length} abgeschlossene Publishing-Aufträge aus dem lokalen Protokoll löschen?`)) return;
try {
const result = await api('DELETE', '/api/publishing/jobs', { ids });
toast(`${result.deleted} Aufträge gelöscht`);
publishState.jobSelected.clear();
await loadPublishingQueueData();
} catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); }
}
function renderPublishingErrorsWorkspace() {
const workspace = document.getElementById('publish-workspace');
const events = publishState.publishEvents || [];
const errors = events.filter(event => event.level === 'error' || ['failed', 'blocked', 'retry_wait'].includes(event.event_type));
workspace.innerHTML = `<header class="publish-operation-header"><div><span class="publish-panel-kicker">Diagnose & Audit</span><h3>Fehlerzentrale und Veröffentlichungsprotokoll</h3><p>${errors.length} relevante Fehler · ${events.length} Ereignisse geladen</p></div><button class="vd-button" type="button" data-publishing-events-refresh>Aktualisieren</button></header>
<div class="publishing-error-summary"><article><strong>${errors.length}</strong><span>Fehlerereignisse</span></article><article><strong>${events.filter(item => item.event_type === 'completed').length}</strong><span>Erfolgreiche Abschlüsse</span></article><article><strong>${events.filter(item => item.event_type === 'retry_wait').length}</strong><span>Automatische Wiederholungen</span></article></div>
<div class="publishing-event-list">${events.length ? events.map(renderPublishingEvent).join('') : '<div class="publish-workspace-empty compact"><span>✓</span><h3>Keine Ereignisse</h3><p>Neue Veröffentlichungsaktivitäten erscheinen hier.</p></div>'}</div>`;
workspace.querySelector('[data-publishing-events-refresh]')?.addEventListener('click', () => loadPublishingQueueData());
workspace.querySelectorAll('[data-event-open-job]').forEach(button => button.addEventListener('click', () => {
publishState.jobSearch = button.dataset.eventOpenJob;
publishState.jobStatus = '';
publishState.jobPage = 1;
setPublishView('queue');
loadPublishingQueueData();
}));
}
function renderPublishingEvent(event) {
return `<article class="publishing-event-row level-${escapeHtml(event.level)}">
<span class="publishing-event-dot"></span>
<div><strong>${escapeHtml(event.message)}</strong><small>${escapeHtml(event.title || event.sku || '')} · ${escapeHtml(platformLabel(event.platform))} · ${escapeHtml(publishingDate(event.created_at))}</small></div>
<span class="publishing-event-type">${escapeHtml(event.event_type)}</span>
${event.job_id ? `<button class="vd-button compact" type="button" data-event-open-job="${escapeHtml(event.job_id)}">Auftrag</button>` : '<span></span>'}
</article>`;
}
async function batchEbayAdd() {
const ids = [...publishState.selected];
if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId);
if (!ids.length) return;
let created = 0;
let duplicates = 0;
let failed = 0;
for (const id of ids) {
const listing = publishState.listings.find(item => item.id === id) || publishState.allFiltered.find(item => item.id === id);
if (!listing) continue;
try {
const result = await api('POST', '/api/publishing/jobs', { listing_id: id, platform: listing.platform, action: 'publish' });
created += result.jobs?.length || 0;
duplicates += result.duplicates || 0;
} catch (error) {
if (error.code === 'DUPLICATE_PUBLISH' || /bereits.*veröffentlicht/i.test(error.message)) duplicates++;
else failed++;
}
}
toast(`${created} Auftrag${created === 1 ? '' : 'e'} eingereiht${duplicates ? ` · ${duplicates} bereits vorhanden/live` : ''}${failed ? ` · ${failed} fehlgeschlagen` : ''}`);
publishState.view = 'queue';
await loadPublishList();
setPublishView('queue');
}
async function processEbayQueue() {
setPublishView('queue');
await loadPublishingQueueData();
}
async function retryEbayQueue() {
setPublishView('errors');
await loadPublishingQueueData();
}
async function openEbayQueue() {
setPublishView('queue');
await loadPublishingQueueData();
}
function publishQueueCounts() {
const summary = publishState.jobsData?.summary || {};
return {
pending: Number(summary.queued || 0) + Number(summary.awaiting_user || 0) + Number(summary.retry_wait || 0),
processing: Number(summary.running || 0),
published: Number(summary.published || 0),
failed: Number(summary.failed || 0) + Number(summary.blocked || 0),
};
}
function renderPublishStatusRail() {
const counts = publishQueueCounts();
const summary = publishState.jobsData?.summary || {};
const workflowPublished = publishState.workflow?.stages?.published?.count || 0;
const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length;
const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; };
setText('publish-status-pending', counts.pending);
setText('publish-status-processing', counts.processing);
setText('publish-status-published', Math.max(counts.published, workflowPublished));
setText('publish-status-failed', counts.failed);
setText('publish-status-scheduled', publishState.scheduled.length);
setText('publish-direct-count', directEligible);
setText('publish-queue-count', publishState.jobsData?.pagination?.total || 0);
setText('publish-error-count', counts.failed);
setText('publish-scheduled-count', publishState.scheduled.length);
setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler prüfen` : 'Keine Fehler');
setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen');
const container = document.getElementById('publish-activity');
const events = publishState.publishEvents || [];
if (container) container.innerHTML = events.length ? events.slice(0, 6).map(event => `<div class="publish-activity-row"><span class="publish-activity-icon type-${escapeHtml(event.level || 'info')}">${event.level === 'error' ? '!' : event.event_type === 'completed' ? '✓' : '•'}</span><div><strong>${escapeHtml(event.message || '')}</strong><small>${relativeTime(event.created_at)}</small></div></div>`).join('') : '<div class="publish-rail-empty">Noch keine Publishing-Aktivität</div>';
}
// 1.39.0: Inline-Eventhandler entfernt; zentrale CSP-kompatible Delegation.
document.addEventListener('click', event => {
const close = event.target.closest?.('[data-close-target]');
if (close) document.getElementById(close.dataset.closeTarget)?.classList.add('hidden');
const kill = event.target.closest?.('[data-kill-session-id]');
if (kill) killSession(Number(kill.dataset.killSessionId));
const own = event.target.closest?.('[data-end-my-session-id]');
if (own) endMySession(Number(own.dataset.endMySessionId));
});