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) => `
Produktbild-Variante ${index + 1}
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 = `Foto ${i + 1}`; 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 = `
Artikel vollständig bearbeiten

${escapeHtml(l.title || 'Unbenanntes Listing')}

${escapeHtml(skuVal)} · ${escapeHtml(new Date((l.created_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE'))}

02

Listing-Daten

03

Beschreibung

0 Zeichen
WYSIWYGFormatierte Bearbeitung
Synchron
04

Eigenschaften & Preis

`; 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 += `
${escapeHtml(loc.storage_location)}
${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 += `
Ohne Lagerort
${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 `
${l.sku ? `${escapeHtml(l.sku)}` : ''} ${escapeHtml(l.title || 'Ohne Titel')}
${pName} · ${l.price ? l.price.toFixed(2) + ' €' : '–'} · ${l.status || 'active'}
`; }).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 `
${photo ? `` : 'Kein Bild'}
${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'}
Plattform${escapeHtml(target.name)}
Preis${escapeHtml(formatCurrency(listing.price) || 'Nicht gesetzt')}
Zustand${escapeHtml(listing.condition || 'Nicht gesetzt')}
Marke${escapeHtml(listing.brand || 'Nicht gesetzt')}
Kategorie${escapeHtml(listing.category || 'Nicht gesetzt')}
Größe${escapeHtml(listing.size || 'Nicht gesetzt')}
`; 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 `
${photo ? `` : 'Kein Bild'}
${escapeHtml(kicker)}

${escapeHtml(listing.title || 'Ohne Titel')}

${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)} · ${escapeHtml(platformLabel(listing.platform))}

${escapeHtml(stateLabel)}
`; } 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')}
${String(fields.title || '').length} Zeichen
${tags ? tags.split(',').filter(Boolean).length : 0}
${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')}
${String(listing.title || '').length}/80
${String(listing.description || '').length}/5000
${Array.isArray(listing.tags) ? listing.tags.length : 0}
`; 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 = `
eBay Automatisierung

eBay Queue

${counts.pending} ausstehend · ${counts.processing} in Bearbeitung · ${counts.failed} fehlgeschlagen

${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'}
${item.ebay_url ? `Ansehen` : ''}
`; } function renderScheduledWorkspace() { const workspace = document.getElementById('publish-workspace'); const items = publishState.scheduled; workspace.innerHTML = `
Zeitsteuerung

Geplante Veröffentlichungen

${items.length} Planung${items.length === 1 ? '' : 'en'} im System

${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 `
${canFavorite ? `` : ''}
${mediaSourceBadges(item)}
${escapeHtml(title)} ${escapeHtml(dimensions)} · ${escapeHtml(formatMediaFileSize(item.file_size))} ${escapeHtml(formatFluxDate(item.created_at))} · ${escapeHtml(item.image_path.split('/').pop())}
${mediaActionIcon('download')}Download
`; }).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 ? `
Prompt

${escapeHtml(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 …')}
  1. Installation vorbereiten
  2. ${escapeHtml(browser.managementUrl)} öffnen
  3. ${escapeHtml(browser.action)}
${window.VendooIcons?.svg('download', { size: 17 }) || ''}
${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)}
`; }).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 = ` `; 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 ` `; } 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 ` `; } 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 = ``; 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 => ` `).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 = `${yGrid}${labels}${points.map(point => `${new Date(point.item.date).toLocaleDateString('de-DE')}: ${formatCurrency(point.value)}`).join('')}`; 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] ? `${escapeHtml(item.title)}` : '
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))}
` : ''}
`; 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 reserviert

Andere Benutzer können diesen Artikel ansehen, aber nicht gleichzeitig speichern.

` : `
${escapeHtml(lock.user_name || lock.user_email || 'Ein anderer Benutzer')} bearbeitet diesen Artikel

Du 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 vorbereitet

Vendoo beenden und setup.bat11 – 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 verbunden

Erwartete 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 => `

${escapeHtml(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}
${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 ``; }).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 = `
Zuverlässige Verarbeitung

Publishing-Queue

${total} Aufträge · Schutz vor Doppelveröffentlichung · automatische Wiederholungen

${selectedCount} ausgewählt
${items.length ? items.map(renderPublishingJobRow).join('') : '

Keine Aufträge

Wähle Artikel aus und füge sie zur Publishing-Queue hinzu.

'}
Seite ${page} von ${pages}
`; 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 = `
Diagnose & Audit

Fehlerzentrale und Veröffentlichungsprotokoll

${errors.length} relevante Fehler · ${events.length} Ereignisse geladen

${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)); });