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(`
${String(input || '')}`, '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 => `${escapeHtml(paragraph).replace(/\n/g, '
')}
`).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>|
<\/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 = `
${String(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 = `
${escapeHtml(message)}${progress}%
${job?.status === 'queued' ? 'Warteschlange' : job?.status === 'running' ? 'Lokale Verarbeitung läuft' : job?.status === 'completed' ? 'Abgeschlossen' : 'Aktion beendet'}
`;
}
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 `
${items.length} sichere Bildvariante${items.length === 1 ? '' : 'n'}
${items.map((photo, index) => `
Variante ${photo.variant_index || index + 1}
${escapeHtml((photo.mode || 'flatlay').replace('_', ' ').toUpperCase())} · ${escapeHtml((photo.preset || 'mixed').toUpperCase())}
`).join('')}
`;
}
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 ``;
}).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 = 'Noch keine passenden FLUX-Bilder gespeichert.
';
return;
}
container.innerHTML = state.fluxStudioHistory.map(item => `
${escapeHtml(item.prompt || 'FLUX-Bild')}${item.width} × ${item.height} · Seed ${item.seed ?? '–'} · ${formatFluxDate(item.created_at)}
`).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 = 'FLUX-Bibliothek wird geladen …
';
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 = `Bibliothek konnte nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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
? `Archivierte Aufträge${summary.archived_total || 0}
Auf dieser Seite${state.fluxJobs.length}
Seiten${state.fluxJobPagination.total_pages || 1}
Bilder bleiben erhaltenJa
`
: `Aktive Worker${summary.active_workers || 0} / ${summary.max_parallel || 1}
Wartende Varianten${variants.queued || 0}
Laufende Varianten${variants.running || 0}
Fertige Aufträge${(jobs.completed || 0) + (jobs.completed_with_errors || 0)}
`;
}
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 = `${state.fluxJobArchiveMode ? 'Das Auftragsarchiv ist leer.' : 'Keine Aufträge für diesen Filter.'}
`;
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
? ``
: `${variant.variant_index}${escapeHtml(fluxStatusLabel(variant.status))} · Seed ${variant.seed}
`).join('');
const ledger = variants.map(variant => `V${variant.variant_index}Seed ${variant.seed}${escapeHtml(variant.phase || variant.status)}${variant.prompt_id ? `${escapeHtml(String(variant.prompt_id).slice(0, 12))}${String(variant.prompt_id).length > 12 ? '…' : ''}` : 'Noch keine Prompt-ID'}
`).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 `
${job.width} × ${job.height}${job.steps} Schritte${escapeHtml(job.style)}${job.variants_count} Variante${job.variants_count === 1 ? '' : 'n'}Seed ${job.seed_base}${job.seed_lock ? ' · Lock' : ''}
${escapeHtml(job.message || '')}${job.error ? `
${escapeHtml(job.error)}` : ''}
${thumbs}
${ledger}
${canCancel ? `` : ''}
${canRetry ? `` : ''}
${canArchive ? `` : ''}
${state.fluxJobArchiveMode ? `` : ''}
`;
}).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 = 'Job-Center wird geladen …
';
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 = `Job-Center konnte nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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 = 'Keine Systemdaten verfügbar.
'; 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 = `
ComfyUI${runtime.reachable ? 'Erreichbar' : 'Nicht erreichbar'}${escapeHtml(runtime.url || '')}
ComfyUI-Queue${queue.running || 0} aktiv · ${queue.pending || 0} wartend${historyCount} Historieneinträge geladen
GPU${escapeHtml(gpu.name || gpu.type || 'Nicht gemeldet')}${gpu.vram_total ? `${bytesToGiB(gpu.vram_free)} frei von ${bytesToGiB(gpu.vram_total)}` : 'VRAM-Daten nicht gemeldet'}
System${escapeHtml(system.os || system.system || system.python_version || 'ComfyUI-System')}${escapeHtml(system.comfyui_version || system.version || '')}
`;
}
async function refreshFluxRuntimeInfo({ silent = false } = {}) {
const container = document.getElementById('flux-runtime-summary');
if (!silent && container) container.innerHTML = 'ComfyUI-Warteschlange, Historie und GPU-Daten werden geladen …
';
try {
state.fluxRuntime = await api('GET', '/api/flux-prompt/runtime');
renderFluxRuntimeInfo();
} catch (error) {
state.fluxRuntime = null;
if (container) container.innerHTML = `Systemdaten nicht verfügbar: ${escapeHtml(error.message)}
`;
}
}
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 = 'Noch keine AI-Bildhistorie vorhanden.
';
return;
}
wrap.innerHTML = generations.slice(0, 6).map(gen => `
${escapeHtml((gen.mode || 'model').replace('_', ' '))}${escapeHtml((gen.preset || 'mixed').toUpperCase())} · ${gen.variants || 0} Bild/Bilder
${(gen.photos || []).slice(0, 3).map(photo => `
`).join('')}
`).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
? `` + prov.models.map(m => ``).join('')
: prov.models.map(m => ``).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 = `
`;
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 => `${escapeHtml(tag)}`).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 || '')}) → Netto: ${fees.net} €`;
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}) → Netto: ${fees.net} €`;
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('…');
parts.push(``);
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 'Nicht veröffentlicht';
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 `${escapeHtml(platformLabel(platform))}${escapeHtml(meta[0])}`;
}).join('');
}
function renderHistoryList(listings) {
const container = document.getElementById('history-list');
if (!listings.length) {
container.innerHTML = `
Keine Artikel gefunden
Ändere die Filter oder erstelle ein neues Listing.
`;
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]
? `
`
: '';
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 `
${photo}
SKU ${escapeHtml(sku)}
${escapeHtml(platform)}${escapeHtml((listing.language || 'de').toUpperCase())}
${escapeHtml(provider.slice(0, 1))}${escapeHtml(provider)}Vision AI
${escapeHtml(price)}Festpreis
Seit ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at))}
${escapeHtml(listing.storage_location || 'Nicht zugewiesen')}${escapeHtml(listing.category || '—')}
${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at, true))}${historyPublishSummary(listing)}
`;
}).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 => ``).join('');
}
function detailProviderOptions(selected) {
return state.providers.map(provider => ``).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]) => ``).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) => `
`).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 = `
WYSIWYGFormatierte Bearbeitung
`;
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 = 'Keine Vorlagen vorhanden
'; return; }
c.innerHTML = state.templates.map(t => {
const pName = state.platforms.find(p => p.id === t.platform)?.name || 'Alle Plattformen';
return `
${escapeHtml(t.name)}
${pName} · ${t.language.toUpperCase()}
`;
}).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 = '' +
state.templates.map(t => ``).join('');
}
function populateTemplateSelects(platforms) {
const sel = document.getElementById('tpl-platform');
sel.innerHTML = '' +
platforms.map(p => ``).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 = `
${totalItems}
Artikel gesamt
${locations.length}
Lagerorte
${totalActive}
Aktive Artikel
${totalValue.toFixed(0)} €
Lagerwert
`;
const locsEl = document.getElementById('inv-locations');
let html = '';
for (const loc of locations) {
html += `
${loc.count} Artikel
${loc.active} aktiv
${loc.sold ? `${loc.sold} verkauft` : ''}
${loc.reserved ? `${loc.reserved} reserviert` : ''}
${loc.total_value.toFixed(0)} €
`;
}
if (unassigned.length) {
html += `
${unassigned.length} Artikel
`;
}
if (!html) html = 'Noch keine Lagerorte vorhanden. Weise Artikeln einen Lagerort zu.
';
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 = 'Keine Artikel an diesem Lagerort
';
} 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 ``;
}).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 `
${escapeHtml(name)} ${f.seller_type === 'privat' ? 'Privat' : 'Shop'}
${escapeHtml(f.breakdown || '')}
${f.note ? `
${escapeHtml(f.note)}
` : ''}
${Number(f.net || 0).toFixed(2)} €
${Number(f.fees) === 0 ? 'Keine Verkäufergebühr' : `-${Number(f.fees).toFixed(2)} € Gebühren`}
`;
}).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 = '' +
state.platforms.map(platform => ``).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 = 'Artikel werden geladen…
';
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 = `Artikel konnten nicht geladen werden${escapeHtml(error.message)}
`;
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 = 'Keine Artikel gefundenÄndere Suche oder Filter.
';
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 `
${escapeHtml(listing.title || 'Ohne Titel')}
${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)}
${escapeHtml(platformLabel(listing.platform))}${formatCurrency(listing.price) || 'Preis fehlt'}
${escapeHtml(status.label)}
`;
}).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 = `↗Artikel auswählen
Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.
`;
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')}
Verkaufsdaten
${escapeHtml(listing.title || 'Ohne Titel')}
${escapeHtml(listing.description || 'Noch keine Beschreibung vorhanden.').replace(/\n/g, '
')}
${tags.length ? tags.slice(0, 12).map(tag => `${escapeHtml(tag)}`).join('') : 'Keine Tags'}
`;
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 ``;
}
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 'Keine Fotos hinterlegt
';
const visible = photos.slice(0, limit);
return `${visible.map(photo => `
})
`).join('')}${photos.length > limit ? `
+${photos.length - limit} weitere` : ''}
`;
}
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')}Daten werden vorbereitet …
`;
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')}
${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')}
`;
bindPublishWorkspaceNavigation(workspace);
bindSmartCopyActions(workspace, listing, data, target, fields, tags);
} catch (error) {
if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return;
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Fehler')}Smart Copy konnte nicht vorbereitet werden${escapeHtml(error.message)}
`;
bindPublishWorkspaceNavigation(workspace);
workspace.querySelector('[data-retry-smart-copy]')?.addEventListener('click', () => renderSmartCopyWorkspace(listing));
}
}
function publishReadonlyMeta(label, value, id) {
return ``;
}
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')}
`;
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 = `
${queue.length ? queue.map(item => publishQueueRow(item)).join('') : '
✓Queue ist leer
Wähle links Artikel aus und füge sie zur eBay Queue hinzu.
'}
`;
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 `
${escapeHtml(String(item.sku || 'VD').slice(-2))}${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
Status${escapeHtml(labels[item.status] || item.status)}
Versuche${Number(item.retries) || 0}
${item.error ? `${escapeHtml(item.error)}` : 'Keine Fehler'}
`;
}
function renderScheduledWorkspace() {
const workspace = document.getElementById('publish-workspace');
const items = publishState.scheduled;
workspace.innerHTML = `
${items.length ? items.map(item => publishScheduleRow(item)).join('') : '
◷Nichts geplant
Plane ausgewählte Artikel für einen späteren Zeitpunkt.
'}
`;
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 `
${escapeHtml(dateText || '—')}${escapeHtml(platformLabel(item.platform))}
${escapeHtml(String(item.sku || 'VD').slice(-2))}${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml(item.sku || '')}
${escapeHtml(statusLabels[item.status] || item.status)}
${item.error ? `${escapeHtml(item.error)}` : ''}
${item.status === 'scheduled' ? `` : ''}
`;
}
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 => `${activityIcon(item.type)}${escapeHtml(item.text || '')}${relativeTime(item.at)}
`).join('') : 'Noch keine Publish-Aktivität
';
}
}
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 => `${escapeHtml(listing.sku || `VD-${listing.id}`)} · ${escapeHtml(listing.title || 'Ohne Titel')}`).join('') + (selected.length > 4 ? `+${selected.length - 4} weitere` : '');
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) => ``).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 => `${escapeHtml(MEDIA_SOURCE_LABELS[source] || source)}`).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 = 'Keine Bilder gefundenFilter anpassen oder im FLUX Studio ein neues Bild erzeugen.
';
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 `
${mediaSourceBadges(item)}
${escapeHtml(title)}
${escapeHtml(dimensions)} · ${escapeHtml(formatMediaFileSize(item.file_size))}
${escapeHtml(formatFluxDate(item.created_at))} · ${escapeHtml(item.image_path.split('/').pop())}
`;
}).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 = 'Bildergalerie wird geladen …
';
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 = `Bildergalerie konnte nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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 = `
${mediaSourceBadges(item)}
- Datei
- ${escapeHtml(item.image_path)}
- Abmessungen
- ${item.width && item.height ? `${item.width} × ${item.height}` : 'Unbekannt'}
- Dateigröße
- ${escapeHtml(formatMediaFileSize(item.file_size))}
- Format
- ${escapeHtml(String(item.format || '—').toUpperCase())}
- Erstellt
- ${escapeHtml(formatFluxDate(item.created_at))}
${item.seed != null ? `- Seed
- ${item.seed}
` : ''}
${item.prompt ? `` : ''}`;
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 = `Aktiver Browser${window.VendooIcons?.svg(activeBrowser.icon, { size: 19 }) || ''} ${escapeHtml(activeBrowser.name)}`;
const version = data.version || '1.9.0';
grid.innerHTML = Object.entries(EXTENSION_BROWSERS).map(([id, browser]) => {
const info = data.browsers?.[id] || {};
return `
${window.VendooIcons?.svg(browser.icon, { size: 27 }) || ''}
${id === active ? 'Aktiver Browser' : 'Verfügbar'}
${escapeHtml(browser.name)}
v${escapeHtml(version)}
${escapeHtml(browser.note)}
Installationsordner${escapeHtml(info.path || 'Wird vom Server ermittelt …')}
- Installation vorbereiten
${escapeHtml(browser.managementUrl)} öffnen- ${escapeHtml(browser.action)}
${id === 'safari' ? 'Safari lässt Erweiterungen nur über eine signierte macOS-App zu. Das Paket enthält dafür ein Xcode-Konvertierungsskript.' : 'Die finale Bestätigung erfolgt im Browser; eine stille automatische Installation ist aus Sicherheitsgründen gesperrt.'}
`;
}).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 = 'Extensions werden geprüft …
';
try { renderExtensions(await api('GET', '/api/extensions/info')); }
catch (error) { if (grid) grid.innerHTML = `Extension-Informationen konnten nicht geladen werden: ${escapeHtml(error.message)}
`; }
}
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 `${escapeHtml(title)}${escapeHtml(detail)}
`;
}).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 = [''];
normalized.forEach(model => {
const label = `${model.name || model.id}${model.is_free ? ' · Free' : ''}`;
options.push(``);
});
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 => `
${escapeHtml(model.name || model.id || 'OpenRouter Modell')}
${escapeHtml(model.id || '')}
${model.is_free ? 'Free' : 'Paid'}
Image
${escapeHtml((model.description || 'Kein Beschreibungstext verfügbar.').slice(0, 220))}
`).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 = '';
for (const p of platforms) {
ps.innerHTML += ``;
fs.innerHTML += ``;
}
as.innerHTML = providers.map(p => ``).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 = `
`;
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 = 'Keine Kategorien gefunden
';
openDropdown();
return;
}
dropdown.innerHTML = results.map(r => `
${escapeHtml(r.name)}
${escapeHtml(r.pathStr)}
`).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 = 'Keine Kategorien verfügbar
';
openDropdown();
return;
}
dropdown.innerHTML = tree.map(group => {
const children = (group.children || []).map(sub => {
if (sub.children) {
const leaves = sub.children.map(leaf =>
`${escapeHtml(leaf.name)}
`
).join('');
return `
${escapeHtml(sub.name)}
${leaves}
`;
}
return `${escapeHtml(sub.name)}
`;
}).join('');
return `
${escapeHtml(group.name)}
${children}
`;
}).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 = `
${escapeHtml(path)}
`;
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 = `
Maßstabsgetreue A4-Vorschau
`;
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 `
${qr ? `

` : ''}
Vendoo
${escapeHtml(sku)}
${escapeHtml(title)}
${escapeHtml(platform)}
${printStudioState.showPrice && price ? `${escapeHtml(price)}` : ''}
`;
}
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 `
${photos.length ? `${photos.map(photo => `
`).join('')}
` : ''}
${listing.description ? `Beschreibung
${escapeHtml(String(listing.description).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())}
` : ''}
`;
}
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 = `▤Datenblatt${escapeHtml(listing?.sku || listing?.title || 'Artikel')}
`;
count.textContent = '1 Seite';
pages.innerHTML = `${buildPrintDatasheet(listing)}
`;
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 = `⌗Etiketten${printStudioState.listings.length} Artikel · ${expanded.length} Etiketten
`;
count.textContent = `${pageChunks.length} ${pageChunks.length === 1 ? 'Seite' : 'Seiten'}`;
pages.innerHTML = pageChunks.map(chunk => `
${chunk.map(buildPrintLabel).join('')}
`).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 = 'Papierkorb ist leer
';
return;
}
c.innerHTML = listings.map(l => {
const photo = l.photos?.[0] ? `
` : '';
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 `
${photo}
${escapeHtml(l.title || 'Ohne Titel')}
${pName} · ${priceStr} · Gelöscht: ${deletedDate}
`;
}).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
? ''
: '';
}
// --- 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 = ``;
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 `${Math.round(max * part).toLocaleString('de-DE')} €`;
}).join('');
const step = Math.max(1, Math.floor(timeline.length / 5));
const labels = timeline.map((item, index) => index % step === 0 || index === timeline.length - 1
? `${new Date(item.date).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })}` : '').join('');
container.innerHTML = ``;
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 => `${escapeHtml(formatCurrency(item.revenue))}
${escapeHtml(platformLabel(item.platform))} `).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 = `${escapeHtml(formatCurrency(total))}Gesamt
${data.map((item, index) => `
${escapeHtml(item.category)}${Math.round((Number(item.revenue) || 0) / total * 1000) / 10}%
`).join('')}
`;
}
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]) => ``).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 => `${item.photo ? ` )}) ` : ' '} ${escapeHtml(item.title || 'Ohne Titel')}${escapeHtml([item.color, item.size].filter(Boolean).join(' / '))} | ${escapeHtml(item.sku || '')} | ${escapeHtml(platformLabel(item.platform))} | ${escapeHtml(formatCurrency(item.revenue))} | ${item.sales || 1} | Verkauft |
`).join('') : '| Noch keine Verkäufe vorhanden. |
';
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 = 'Keine Einträge
'; return; }
container.innerHTML = items.slice(0, 6).map(item => ``).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 = 'Für diesen Filter sind keine Artikel vorhanden.
'; return; }
container.innerHTML = items.map(item => ``).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 => ` | ${escapeHtml(platformLabel(item.platform))} | ${item.scheduledAt ? new Date(item.scheduledAt.replace(' ', 'T')).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'} | Geplant | |
`).join('') : '| Keine geplanten Veröffentlichungen. |
';
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 = 'Alles erledigt – aktuell ist keine Aktion nötig.
'; 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 => ``).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 => ``).join('') : 'Noch keine Aktivität
';
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] ? `
` : 'Kein Bild
';
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 = `${escapeHtml(item.title)}
SKU ${escapeHtml(item.sku || '')}
${item.status === 'sold' ? 'Verkauft' : 'Bereit'}${hero}▧ ${photos.length}
${photos.length > 1 ? `${photos.slice(0, 5).map(photo => `
)})
`).join('')}${photos.length > 5 ? `
+${photos.length - 5}` : ''}
` : ''}Preis${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}
AI-Konfidenz
${confidence}%
${confidence >= 85 ? 'Sehr hoch' : confidence >= 60 ? 'Prüfung empfohlen' : 'Unvollständig'}${confidence >= 85 ? 'Alle wichtigen Attribute wurden erkannt.' : 'Einige Angaben sollten ergänzt werden.'}
Wichtige Attribute
- Marke
- ${escapeHtml(item.brand || '—')}
- Kategorie
- ${escapeHtml(item.category || '—')}
- Farbe
- ${escapeHtml(item.color || '—')}
- Größe
- ${escapeHtml(item.size || '—')}
- Zustand
- ${escapeHtml(item.condition || '—')}
- Lagerort
- ${escapeHtml(item.storageLocation || 'Nicht zugewiesen')}
${item.price ? `Preisempfehlung
${escapeHtml(formatCurrency(low))} – ${escapeHtml(formatCurrency(high))}
Empfohlener Preis${escapeHtml(formatCurrency(item.price))}
` : ''}`;
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 = 'Studio Flow wird geladen…
';
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 = `Studio Flow konnte nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
// --- 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 = 'Versionen werden geladen …
';
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 = 'Noch keine Bearbeitungsversion.
';
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 ``;
}).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: ${data.suggestion}€ (${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
? `Bearbeitung für dich reserviertAndere Benutzer können diesen Artikel ansehen, aber nicht gleichzeitig speichern.
`
: `${escapeHtml(lock.user_name || lock.user_email || 'Ein anderer Benutzer')} bearbeitet diesen ArtikelDu kannst den Artikel ansehen. Speichern ist bis zum Ablauf der Sperre deaktiviert.
`;
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 = `${pending.type === 'update' ? `Update auf ${escapeHtml(pending.version_to || 'neue Version')}` : 'Wiederherstellung'} ist vorbereitetVendoo beenden und setup.bat → 11 – Vorbereitete Operation anwenden ausführen.
`;
}
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 = `Vendoo ${escapeHtml(status.version || VENDOO_UI_BUILD)}${last ? `Letzte Operation: ${escapeHtml(last.type || 'System')} · ${escapeHtml(last.status || 'unbekannt')}` : 'Noch keine Setup-Operation protokolliert.'}`;
}
const migrations = document.getElementById('operations-migration-list');
if (migrations) {
const rows = status.migrations || [];
migrations.innerHTML = rows.length ? rows.slice(0, 8).map(row => `${escapeHtml(row.app_version)}${escapeHtml(row.description || row.migration_key)} · ${operationFormatDate(row.applied_at)}
`).join('') : 'Keine Migrationsinformationen vorhanden.
';
}
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 = 'Noch kein Backup vorhanden.
';
return;
}
list.innerHTML = backups.map(item => {
const verified = item.status === 'verified';
const includesUploads = item.options?.includeUploads;
return `
${escapeHtml(item.label || item.file_name)}${escapeHtml(item.kind === 'automatic' ? 'Automatisch' : item.kind === 'database' ? 'Nur Datenbank' : 'Manuell')} · ${operationFormatDate(item.created_at)}${operationFormatBytes(item.file_size)} · ${includesUploads ? 'mit Uploads' : 'ohne Uploads'}${item.options?.includeConfig ? ' · enthält .env' : ''} · ${verified ? 'verifiziert' : escapeHtml(item.status)}
${escapeHtml(String(item.sha256 || '').slice(0, 16))}…
`;
}).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 = `${Number(data.connected || 0)} Browser live verbundenErwartete Extension-Version: ${escapeHtml(data.expected_version || '—')}
`;
if (packages) packages.innerHTML = Object.entries(data.packages || {}).map(([browser, item]) => `${escapeHtml(browser[0].toUpperCase() + browser.slice(1))}${item.exists ? `Paket v${escapeHtml(item.version || '?')}` : 'Paket fehlt'}${item.version && item.version !== data.expected_version ? ` · erwartet ${escapeHtml(data.expected_version)}` : ''}
`).join('');
if (clients) {
const rows = data.clients || [];
clients.innerHTML = rows.length ? rows.map(client => {
const live = Number(client.age_seconds || 999999) <= 180;
return `${escapeHtml(client.browser || 'Browser')} · v${escapeHtml(client.version || '?')}${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.`}${escapeHtml(client.server_url || '')}
${live ? 'Live' : 'Offline'}`;
}).join('') : 'Noch keine Extension-Verbindung gemeldet. Öffne nach dem Update die Extension oder eine unterstützte Vinted-Seite.
';
}
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 = `${escapeHtml(error.message)}
`; }
}
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 = `${escapeHtml(error.message)}
`; }
}
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 = `${data.configured ? 'Coolify ist konfiguriert' : 'Coolify noch nicht vollständig konfiguriert'}Provider: ${escapeHtml(data.provider || 'none')} · Trigger: ${escapeHtml(data.trigger_mode || 'webhook')} · Docker-Socket: ${data.docker_socket_access ? 'vorhanden' : 'nicht freigegeben'}`;
const action = document.getElementById('deployment-action-result');
if (action && data.last_deployment) action.innerHTML = `${data.last_deployment.accepted ? 'Letzter Deployment-Auftrag angenommen' : 'Letzter Deployment-Auftrag fehlgeschlagen'}${operationFormatDate(data.last_deployment.requested_at)} · Branch ${escapeHtml(data.last_deployment.github_branch || 'main')} · HTTP ${escapeHtml(String(data.last_deployment.http_status || '—'))}`;
}
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 = `Deploymentstatus konnte nicht geladen werden${escapeHtml(error.message)}`; }
}
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) => ``).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 ? `${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : 'Vendoo ist aktuell'}Installiert: ${escapeHtml(result.current_version)}${result.latest_version ? ` · Verfügbar: ${escapeHtml(result.latest_version)}` : ''}` : `Keine Online-Prüfung konfiguriert${escapeHtml(result.message || '')}`;
} catch (error) { if (resultBox) resultBox.innerHTML = `Prüfung fehlgeschlagen${escapeHtml(error.message)}`; }
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 = `Coolify erreichbarHealthcheck HTTP ${escapeHtml(String(result.status || 200))}`; }
catch (error) { if (resultBox) resultBox.innerHTML = `Coolify nicht erreichbar${escapeHtml(error.message)}`; }
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 = `${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.message ? ` · ${escapeHtml(result.message)}` : ''}`; }
catch (error) { if (resultBox) resultBox.innerHTML = `Updateprüfung fehlgeschlagen${escapeHtml(error.message)}`; }
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 = `Coolify-Deployment angenommen${escapeHtml(result.message || '')}${result.backup_file ? ` · Backup: ${escapeHtml(result.backup_file)}` : ''}`;
toast('Coolify-Deployment wurde angefordert.');
await loadDeploymentStatus();
} catch (error) { if (resultBox) resultBox.innerHTML = `Deployment fehlgeschlagen${escapeHtml(error.message)}`; 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 => `${escapeHtml(role.label)}${role.permissions.length} Rechte${escapeHtml(role.description)}
`).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 `${escapeHtml(group)}
Berechtigung${data.roles.map(role => `${escapeHtml(role.label)}`).join('')}
${permissions.map(permission => `
${escapeHtml(permission.label)}${data.roles.map(role => `${role.permissions.includes(permission.key) ? '✓' : '—'}`).join('')}
`).join('')}
`;
}).join('');
} catch (error) {
cards.innerHTML = `${escapeHtml(error.message)}
`;
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 = 'Sicherheitsstatus wird geladen …
';
try {
const status = await api('GET', '/api/security/status');
const secure = !status.remote_binding || status.secure_public_url;
summary.innerHTML = `${secure && !status.warnings.length ? 'Sicherheitsbasis aktiv' : 'Konfiguration braucht Aufmerksamkeit'}Vendoo ${escapeHtml(status.version)} · Bind ${escapeHtml(status.bind_host)}:${status.port} · ${status.trust_proxy ? 'Reverse-Proxy vertraut' : 'Direkte Verbindung'}
`;
const warnings = document.getElementById('security-warning-list');
warnings.innerHTML = status.warnings.length ? status.warnings.map(message => ``).join('') : ' Keine kritischen Konfigurationswarnungen erkannt.
';
document.getElementById('security-network-info').innerHTML = `- Bind-Adresse
${escapeHtml(status.bind_host)}- Öffentliche URL
- ${status.public_base_url ? `
${escapeHtml(status.public_base_url)}` : 'Nicht gesetzt'} - HTTPS
- ${status.secure_public_url ? 'Aktiv' : 'Nicht erkannt'}
- Erlaubte Hosts
- ${status.allowed_hosts.length ? status.allowed_hosts.map(escapeHtml).join(', ') : 'Nur lokale/private Hosts'}
- Erlaubte Origins
- ${status.allowed_origins.length ? status.allowed_origins.map(escapeHtml).join(', ') : 'Nur Same-Origin und Extensions'}
`;
document.getElementById('security-session-info').innerHTML = `- Absolute Sessiondauer
- ${status.session_days} Tage
- Inaktivitätslimit
- ${status.session_idle_minutes} Minuten
- Rate-Limit erlaubt
- ${status.rate_limits.allowed}
- Rate-Limit blockiert
- ${status.rate_limits.blocked}
- Aktive Sperren
- ${status.active_locks.length}
`;
const secretInfo = document.getElementById('security-secret-info');
if (secretInfo) secretInfo.innerHTML = `- Provider
- ${escapeHtml(status.secrets.provider)}
- Verschlüsselt
- ${status.secrets.encrypted}
- Umgebungsvariablen
- ${status.secrets.environment}
- Konfiguriert
- ${status.secrets.configured}
${status.secrets.secrets.filter(item => item.configured).map(item => `${escapeHtml(item.label)} · ${escapeHtml(item.provider)}`).join('') || 'Keine Secrets konfiguriert.'}
`;
const requestInfo = document.getElementById('security-request-info');
if (requestInfo) requestInfo.innerHTML = `- Requests seit Start
- ${status.requests.total}
- Aktiv
- ${status.requests.active}
- Serverfehler
- ${status.requests.errors}
- Sicherheitsblockaden
- ${status.requests.blocked}
- Inline-Skripte
- ${status.csp.script_inline_allowed ? 'Erlaubt' : 'Blockiert'}
- CSP
- ${status.csp.enforced ? 'Erzwungen' : 'Nur Bericht'}
`;
const locks = document.getElementById('security-lock-list');
locks.innerHTML = status.active_locks.length ? status.active_locks.map(lock => `${escapeHtml(lock.resource_type)} #${escapeHtml(lock.resource_id)}${escapeHtml(lock.user_name || lock.user_email)} · bis ${new Date(lock.expires_at).toLocaleTimeString('de-DE')}
`).join('') : 'Keine aktiven Bearbeitungssperren.
';
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 = `${escapeHtml(error.message)}
`; }
}
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 = 'Keine User vorhanden
'; 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() ? 'Vorübergehend gesperrt' : '';
const pwBadge = u.has_password
? 'Passwort gesetzt'
: 'Kein Passwort';
return `
${escapeHtml(initials)}
${escapeHtml(u.name || u.email)} ${escapeHtml(ROLE_LABELS[u.role] || u.role)} ${pwBadge} ${lockedBadge}
${escapeHtml(u.email)}
${u.active ? timeAgo(u.last_active_at) : 'Deaktiviert'} · Erstellt: ${new Date(`${u.created_at}Z`).toLocaleDateString('de-DE')}
${u.id !== currentUser?.id ? `` : ''}
`;
}).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 = 'Keine Login-Einträge
';
} 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 `
${dt}
${h.ip || '—'}
${statusText}
`;
}).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 = 'Keine aktiven Sessions
'; return; }
container.innerHTML = sessions.map(s => {
const ua = parseUA(s.user_agent);
return `
${s.name || s.email} ${s.role}
${ua}
IP: ${s.ip || 'unbekannt'}
${timeAgo(s.created_at)}
Läuft ab: ${new Date(s.expires_at.endsWith('Z') ? s.expires_at : s.expires_at + 'Z').toLocaleDateString('de-DE')}
`;
}).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 = 'Keine passenden Audit-Einträge
'; return; }
container.innerHTML = logs.map(l => {
const actionParts = (l.action || '').split('.');
const category = actionParts[0] || 'other';
return `${new Date(String(l.created_at).replace(' ', 'T') + 'Z').toLocaleString('de-DE')}
${escapeHtml(l.user_email || 'System')}${escapeHtml(l.ip || '')}
${escapeHtml(l.action)}${l.target_type ? `
${escapeHtml(l.target_type)} ${escapeHtml(l.target_id || '')}` : ''}${l.details ? `
${escapeHtml(l.details)}
` : ''}
`;
}).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 = 'Keine passenden Bilder gefunden. Passe Suche oder Quellenfilter an.
';
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 ``;
}).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 = 'Keine Produktionsprofile vorhanden.
';
return;
}
container.innerHTML = state.imageFactoryProfiles.map(profile => {
const selected = state.imageFactorySelectedProfiles.has(Number(profile.id));
return `
${profile.is_builtin ? 'P' : 'E'}
${escapeHtml(profile.name)}${escapeHtml(profile.description || 'Eigenes Produktionsprofil')}
${escapeHtml(profileDimensionLabel(profile))}
${profile.is_builtin ? 'Standard' : ``}
`;
}).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 = 'Bilder werden geladen …
';
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 = `Bilder konnten nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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 = `Profile konnten nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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 'Details werden geladen …
';
return `${job.items.map(item => `
${escapeHtml(item.profile_name)}${escapeHtml(item.source_path.split('/').pop())}${item.error_message ? `${escapeHtml(item.error_message)}` : ''}
${escapeHtml(batchItemStatusLabel(item.status))}
${['failed','cancelled'].includes(item.status) ? `` : ''}
${item.output_path ? `Download` : ''}
`).join('')}
`;
}
function renderImageFactoryJobs() {
const container = document.getElementById('image-factory-jobs');
if (!container) return;
if (!state.imageFactoryJobs.length) {
container.innerHTML = 'Noch keine Produktionsaufträge. Wähle Bilder und Profile und starte die erste Produktion.
';
} 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 `
${['queued','running'].includes(job.status) ? `
` : ''}
${job.status === 'paused' ? `
` : ''}
${['failed','partial','cancelled'].includes(job.status) ? `
` : ''}
${Number(job.completed_items || 0) > 0 ? `
Ergebnisse als ZIP` : ''}
${terminal ? `
` : ''}
${expanded ? renderImageFactoryJobItems(job) : ''}
`;
}).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 = 'Produktionsaufträge werden geladen …
';
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 = `Aufträge konnten nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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 = 'Keine Artikel entsprechen den gewählten Filtern.
';
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 ``;
}).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 = 'Artikelqualität wird geladen …
';
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 = `Quality Center konnte nicht geladen werden: ${escapeHtml(error.message)}
`;
}
}
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 = 'Keine Punkte in diesem BereichDer Artikel erfüllt die aktuell geprüften Qualitätsregeln.
';
return;
}
container.innerHTML = issues.map(item => `
${qualityIssueIcon(item.severity)}
${escapeHtml(item.title)}${escapeHtml(item.detail || '')}
${escapeHtml(item.category || '')}${item.field ? ` · Feld: ${escapeHtml(item.field)}` : ''}
−${Number(item.points || 0)}
`).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 = `${report.ready ? 'Bereit zur Veröffentlichung' : report.blockers ? 'Veröffentlichung blockiert' : 'Überarbeitung empfohlen'}${report.blockers || 0} Blocker · ${report.warnings || 0} Warnungen · ${report.recommendations || 0} Empfehlungen`;
const finance = report.financials || {};
const financials = document.getElementById('quality-financials');
if (financials) financials.innerHTML = `Preis${formatCurrency(finance.price || report.listing?.price || 0)}
Gebühren${formatCurrency(finance.fees || 0)}
Geschätzter Erlös${formatCurrency(finance.estimated_net || 0)}
`;
renderQualityIssues(report);
const duplicates = document.getElementById('quality-duplicates');
if (duplicates) duplicates.innerHTML = report.duplicates?.length ? report.duplicates.map(item => ``).join('') : 'Keine auffälligen Duplikate gefunden.
';
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 => `${item.missing || item.unreadable ? '!' : '✓'}${escapeHtml(item.path || 'Bild')}${item.width ? `${item.width} × ${item.height} px · ${item.megapixels} MP · ${escapeHtml(item.format || '')}` : item.missing ? 'Datei fehlt' : 'Nicht lesbar'}
`).join('') : 'Für eine vollständige Bildprüfung bitte „Neu analysieren“ verwenden.
';
const history = document.getElementById('quality-history');
if (history) history.innerHTML = report.history?.length ? report.history.map(item => `${new Date(String(item.analyzed_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE')}${item.score}Note ${item.grade}
`).join('') : 'Noch kein gespeicherter Verlauf.
';
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 => `${escapeHtml(reason)}`).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 = `${errors ? `${errors} kritische Prüfung(en) fehlgeschlagen` : 'Vendoo-Kernsystem ist betriebsbereit'}${warnings ? `${warnings} Hinweis(e) · ` : ''}${report.checks?.length || 0} Prüfungen in ${report.duration_ms || 0} ms · Build ${escapeHtml(report.version || VENDOO_UI_BUILD)}`;
grid.innerHTML = (report.checks || []).map(check => `
${diagnosticIcon(check.status)}
${escapeHtml(check.label)}${escapeHtml(check.detail || '')}
${check.status === 'ok' ? 'OK' : check.status === 'error' ? 'Fehler' : check.status === 'warning' ? 'Hinweis' : 'Optional'}
`).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 = '
';
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 = `
Server Uptime
${sys.uptime}
Benutzer
${sys.total_users}
Artikel
${sys.total_listings}
Aktive Sessions
${sys.active_sessions}
Datenbankgröße
${sys.db_size}
Node.js
${sys.node_version}
Speicher (RAM)
${sys.memory_mb} MB
Plattform
${sys.platform}
`;
} 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 `
${ua}
IP: ${s.ip || 'unbekannt'}
${timeAgo(s.created_at)}
`;
}).join('') : 'Keine aktiven Sessions
';
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 = `↗Artikel auswählen
Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.
`;
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 => ``).join('');
const statusOptions = ['', 'queued', 'running', 'awaiting_user', 'retry_wait', 'published', 'drafted', 'failed', 'cancelled', 'ended'].map(value => ``).join('');
workspace.innerHTML = `
${selectedCount} ausgewählt
${items.length ? items.map(renderPublishingJobRow).join('') : '
✓Keine Aufträge
Wähle Artikel aus und füge sie zur Publishing-Queue hinzu.
'}
`;
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 `
${escapeHtml(String(job.sku || 'VD').slice(-2))}${escapeHtml(job.title || 'Ohne Titel')}${escapeHtml(job.sku || '')} · ${escapeHtml(platformLabel(job.platform))}
Aktion${escapeHtml(publishingActionLabel(job.action))}${escapeHtml(publishingModeLabel(job.mode))}
Status${escapeHtml(publishingStatusLabel(job.status))}${escapeHtml(publishingDate(job.updated_at))}
Versuche${Number(job.retries)}/${Number(job.max_retries)}${job.next_attempt_at ? `Nächster: ${escapeHtml(publishingDate(job.next_attempt_at))}` : ''}
${job.error_message ? `${escapeHtml(job.error_message)}` : job.external_id ? `Externe ID: ${escapeHtml(job.external_id)}` : 'Keine Fehler'}
${job.external_url ? `
↗` : ''}
${isLive && publishingCan(job, 'sync') ? `
` : ''}
${isLive && publishingCan(job, 'update') ? `
` : ''}
${isLive && publishingCan(job, 'end') ? `
` : ''}
${canRetry ? `
` : ''}
${canCancel ? `
` : ''}
${canDelete ? `
` : ''}
`;
}
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 = `
${errors.length}Fehlerereignisse${events.filter(item => item.event_type === 'completed').length}Erfolgreiche Abschlüsse${events.filter(item => item.event_type === 'retry_wait').length}Automatische Wiederholungen
${events.length ? events.map(renderPublishingEvent).join('') : '
✓Keine Ereignisse
Neue Veröffentlichungsaktivitäten erscheinen hier.
'}
`;
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 `
${escapeHtml(event.message)}${escapeHtml(event.title || event.sku || '')} · ${escapeHtml(platformLabel(event.platform))} · ${escapeHtml(publishingDate(event.created_at))}
${escapeHtml(event.event_type)}
${event.job_id ? `` : ''}
`;
}
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 => `${event.level === 'error' ? '!' : event.event_type === 'completed' ? '✓' : '•'}${escapeHtml(event.message || '')}${relativeTime(event.created_at)}
`).join('') : 'Noch keine Publishing-Aktivität
';
}
// 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));
});