Manifestbasierter Production-Updater 3.3, Modulnavigation, optionales Produktbild Studio, Listing-Studio-Aktionsleiste sowie plattformübergreifende Windows-/Linux-Release-Gates.
9970 lines
561 KiB
JavaScript
9970 lines
561 KiB
JavaScript
const VENDOO_UI_BUILD = '1.43.0';
|
||
|
||
const state = {
|
||
photos: [],
|
||
platform: '',
|
||
aiProvider: 'claude',
|
||
aiModel: '',
|
||
language: 'de',
|
||
variants: 1,
|
||
currentListing: null,
|
||
allVariants: null,
|
||
platforms: [],
|
||
providers: [],
|
||
templates: [],
|
||
editingTemplateId: null,
|
||
historyPage: 1,
|
||
historyPageSize: 10,
|
||
publishPage: 1,
|
||
publishPageSize: 10,
|
||
aiModelPhotos: [],
|
||
aiModelGenerations: [],
|
||
aiPromptScope: 'generator',
|
||
aiPromptDrafts: {
|
||
generator: { customPrompt: '', preserveLogos: true },
|
||
detail: { customPrompt: '', preserveLogos: true },
|
||
},
|
||
fluxStudioCurrent: null,
|
||
fluxStudioHistory: [],
|
||
fluxHistoryPage: 1,
|
||
fluxHistoryPageSize: 5,
|
||
fluxHistoryPagination: { page: 1, total_pages: 1, total: 0 },
|
||
fluxStudioBusy: false,
|
||
fluxJobs: [],
|
||
fluxJobSummary: null,
|
||
fluxRuntime: null,
|
||
fluxStudioWatchers: new Map(),
|
||
fluxJobExpanded: new Set(),
|
||
fluxJobManuallyToggled: new Set(),
|
||
fluxJobCenterCollapsed: false,
|
||
fluxJobArchiveMode: false,
|
||
fluxJobPage: 1,
|
||
fluxJobPageSize: 10,
|
||
fluxJobPagination: { page: 1, total_pages: 1, total: 0 },
|
||
fluxJobSelected: new Set(),
|
||
mediaItems: [],
|
||
mediaSelected: new Set(),
|
||
mediaPage: 1,
|
||
mediaPageSize: 24,
|
||
mediaPagination: { page: 1, total_pages: 1, total: 0 },
|
||
mediaSummary: null,
|
||
mediaView: 'grid',
|
||
mediaCurrent: null,
|
||
imageFactoryProfiles: [],
|
||
imageFactorySources: [],
|
||
imageFactorySelectedSources: new Set(),
|
||
imageFactorySelectedProfiles: new Set(),
|
||
imageFactoryJobs: [],
|
||
imageFactoryJobPage: 1,
|
||
imageFactoryJobPageSize: 10,
|
||
imageFactoryJobPagination: { page: 1, total_pages: 1, total: 0 },
|
||
imageFactorySummary: null,
|
||
imageFactoryExpanded: new Set(),
|
||
qualityItems: [],
|
||
qualityPage: 1,
|
||
qualityPageSize: 10,
|
||
qualityPagination: { page: 1, total_pages: 1, total: 0 },
|
||
qualitySummary: null,
|
||
qualityCurrent: null,
|
||
qualityIssueFilter: 'all',
|
||
qualityAiDraft: null,
|
||
};
|
||
|
||
const editors = {};
|
||
|
||
const htmlSyncState = { generatorLock: false, detailLock: false };
|
||
let detailEditorState = null;
|
||
const listingEditLock = { listingId: null, token: null, lock: null, timer: null };
|
||
const mobileUploadState = { session: null, pollTimer: null, target: null, imported: new Set() };
|
||
|
||
|
||
const QUILL_TOOLBAR = [
|
||
[{ header: [1, 2, 3, false] }],
|
||
['bold', 'italic', 'underline', 'strike'],
|
||
[{ color: [] }, { background: [] }],
|
||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||
[{ align: [] }],
|
||
['blockquote', 'link'],
|
||
['clean'],
|
||
];
|
||
|
||
function initQuill(container, placeholder) {
|
||
if (typeof Quill === 'undefined') return null;
|
||
return new Quill(container, {
|
||
theme: 'snow',
|
||
placeholder: placeholder || '',
|
||
modules: { toolbar: QUILL_TOOLBAR },
|
||
});
|
||
}
|
||
|
||
const RICH_ALLOWED_TAGS = new Set([
|
||
'P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'STRIKE', 'OL', 'UL', 'LI',
|
||
'BLOCKQUOTE', 'A', 'H1', 'H2', 'H3', 'SPAN', 'DIV', 'SUB', 'SUP', 'CODE', 'PRE',
|
||
]);
|
||
const RICH_DANGEROUS_TAGS = new Set([
|
||
'SCRIPT', 'STYLE', 'IFRAME', 'OBJECT', 'EMBED', 'FORM', 'INPUT', 'BUTTON', 'TEXTAREA',
|
||
'SELECT', 'OPTION', 'SVG', 'MATH', 'VIDEO', 'AUDIO', 'LINK', 'META',
|
||
]);
|
||
|
||
function sanitizeStyleClient(style) {
|
||
return String(style || '').split(';').map(part => part.trim()).filter(Boolean).map(part => {
|
||
const index = part.indexOf(':');
|
||
if (index < 1) return '';
|
||
const name = part.slice(0, index).trim().toLowerCase();
|
||
const value = part.slice(index + 1).trim();
|
||
if (!['color', 'background-color', 'text-align'].includes(name)) return '';
|
||
if (/url\s*\(|expression\s*\(|javascript:/i.test(value)) return '';
|
||
if (name === 'text-align' && !/^(left|right|center|justify)$/i.test(value)) return '';
|
||
if (['color', 'background-color'].includes(name) && !/^(#[0-9a-f]{3,8}|rgba?\([\d\s.,%]+\)|hsla?\([\d\s.,%]+\)|[a-z]{3,20})$/i.test(value)) return '';
|
||
return `${name}:${value}`;
|
||
}).filter(Boolean).join(';');
|
||
}
|
||
|
||
function sanitizeRichHtmlClient(input) {
|
||
const doc = new DOMParser().parseFromString(`<body>${String(input || '')}</body>`, 'text/html');
|
||
const elements = [...doc.body.querySelectorAll('*')].reverse();
|
||
for (const element of elements) {
|
||
if (RICH_DANGEROUS_TAGS.has(element.tagName)) {
|
||
element.remove();
|
||
continue;
|
||
}
|
||
if (!RICH_ALLOWED_TAGS.has(element.tagName)) {
|
||
element.replaceWith(...element.childNodes);
|
||
continue;
|
||
}
|
||
for (const attr of [...element.attributes]) {
|
||
const name = attr.name.toLowerCase();
|
||
const value = attr.value;
|
||
let keep = false;
|
||
if (element.tagName === 'A' && name === 'href' && /^(https?:|mailto:|tel:|#|\/)/i.test(value.trim())) keep = true;
|
||
if (element.tagName === 'A' && name === 'target' && ['_blank', '_self'].includes(value)) keep = true;
|
||
if (element.tagName === 'A' && name === 'rel') { element.setAttribute('rel', 'noopener noreferrer'); keep = true; }
|
||
if (name === 'style') {
|
||
const safeStyle = sanitizeStyleClient(value);
|
||
if (safeStyle) { element.setAttribute('style', safeStyle); keep = true; }
|
||
}
|
||
if (name === 'class' && String(value).split(/\s+/).every(cls => /^ql-[a-z0-9-]+$/i.test(cls))) keep = true;
|
||
if (!keep) element.removeAttribute(attr.name);
|
||
}
|
||
if (element.tagName === 'A' && element.getAttribute('target') === '_blank') element.setAttribute('rel', 'noopener noreferrer');
|
||
}
|
||
return doc.body.innerHTML.trim();
|
||
}
|
||
|
||
function plainTextToRichHtml(text) {
|
||
const value = String(text || '').trim();
|
||
if (!value) return '';
|
||
return value.split(/\n{2,}/).map(paragraph => `<p>${escapeHtml(paragraph).replace(/\n/g, '<br>')}</p>`).join('');
|
||
}
|
||
|
||
function richHtmlToText(html) {
|
||
const container = document.createElement('div');
|
||
container.innerHTML = sanitizeRichHtmlClient(html);
|
||
container.querySelectorAll('br').forEach(br => br.replaceWith('\n'));
|
||
container.querySelectorAll('p,div,h1,h2,h3,li,blockquote,pre').forEach(node => node.append('\n'));
|
||
return (container.textContent || '').replace(/\n{3,}/g, '\n\n').trim();
|
||
}
|
||
|
||
function getEditorHtml(editor) {
|
||
if (!editor) return '';
|
||
const html = sanitizeRichHtmlClient(editor.root.innerHTML);
|
||
return /^(<p><br><\/p>|<p><\/p>)$/i.test(html) ? '' : html;
|
||
}
|
||
|
||
function setEditorHtml(editor, html) {
|
||
if (!editor) return;
|
||
const safe = sanitizeRichHtmlClient(html);
|
||
editor.setText('');
|
||
if (safe) editor.clipboard.dangerouslyPasteHTML(safe);
|
||
}
|
||
|
||
function setSandboxPreview(frame, html) {
|
||
if (!frame) return;
|
||
frame.srcdoc = `<!doctype html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><style>html{background:#fff}body{margin:0;padding:18px;font-family:system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#25231f;font-size:14px;line-height:1.55;overflow-wrap:anywhere}table{max-width:100%}a{color:#d93616}</style></head><body>${String(html || '')}</body></html>`;
|
||
}
|
||
|
||
function setHtmlSyncStatus(id, text, tone = '') {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.textContent = text;
|
||
el.dataset.tone = tone;
|
||
}
|
||
|
||
const clientDiagnostics = { incidents: [], lastSystemReport: null, online: true, healthTimer: null };
|
||
|
||
function recordClientIncident(type, message, details = {}) {
|
||
const incident = {
|
||
at: new Date().toISOString(),
|
||
type: String(type || 'client'),
|
||
message: String(message || 'Unbekannter Fehler').slice(0, 500),
|
||
view: document.querySelector('.tab-content.active')?.id || 'unknown',
|
||
build: VENDOO_UI_BUILD,
|
||
...details,
|
||
};
|
||
clientDiagnostics.incidents.unshift(incident);
|
||
clientDiagnostics.incidents = clientDiagnostics.incidents.slice(0, 30);
|
||
try { localStorage.setItem('vendoo_client_incidents', JSON.stringify(clientDiagnostics.incidents)); } catch {}
|
||
return incident;
|
||
}
|
||
|
||
function setConnectionState(online, detail = '') {
|
||
clientDiagnostics.online = !!online;
|
||
const banner = document.getElementById('connection-banner');
|
||
const title = document.getElementById('connection-banner-title');
|
||
const description = document.getElementById('connection-banner-detail');
|
||
if (!banner) return;
|
||
banner.classList.toggle('hidden', online);
|
||
banner.classList.toggle('is-online', online);
|
||
if (title) title.textContent = online ? 'Verbindung wiederhergestellt' : 'Vendoo ist nicht erreichbar';
|
||
if (description) description.textContent = detail || (online ? 'Die Anwendung ist wieder verbunden.' : 'Verbindung wird automatisch erneut geprüft.');
|
||
}
|
||
|
||
async function checkVendooHealth({ quiet = true } = {}) {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), 3500);
|
||
try {
|
||
const response = await fetch(`/healthz?t=${Date.now()}`, { cache: 'no-store', signal: controller.signal });
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||
const result = await response.json();
|
||
const wasOffline = !clientDiagnostics.online;
|
||
setConnectionState(true, `Server erreichbar · Version ${result.version || VENDOO_UI_BUILD}`);
|
||
if (wasOffline && !quiet) toast('Verbindung zu Vendoo wiederhergestellt.');
|
||
return true;
|
||
} catch (error) {
|
||
setConnectionState(false, error?.name === 'AbortError' ? 'Zeitüberschreitung beim Serverzugriff.' : 'Serververbindung unterbrochen. Automatische Wiederholung läuft.');
|
||
return false;
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
function setupGlobalResilience() {
|
||
try {
|
||
const stored = JSON.parse(localStorage.getItem('vendoo_client_incidents') || '[]');
|
||
if (Array.isArray(stored)) clientDiagnostics.incidents = stored.slice(0, 30);
|
||
} catch {}
|
||
window.addEventListener('error', event => {
|
||
recordClientIncident('javascript', event.message, { file: event.filename || '', line: event.lineno || 0, column: event.colno || 0 });
|
||
});
|
||
window.addEventListener('unhandledrejection', event => {
|
||
const reason = event.reason;
|
||
recordClientIncident('promise', reason?.message || String(reason || 'Unbehandelte Promise-Ablehnung'));
|
||
});
|
||
window.addEventListener('offline', () => setConnectionState(false, 'Der Browser meldet keine Netzwerkverbindung.'));
|
||
window.addEventListener('online', () => checkVendooHealth({ quiet: false }));
|
||
document.getElementById('connection-retry-btn')?.addEventListener('click', () => checkVendooHealth({ quiet: false }));
|
||
checkVendooHealth();
|
||
clientDiagnostics.healthTimer = window.setInterval(() => checkVendooHealth(), 30000);
|
||
window.addEventListener('beforeunload', () => window.clearInterval(clientDiagnostics.healthTimer), { once: true });
|
||
}
|
||
|
||
function applyResponsiveContract() {
|
||
const main = document.querySelector('.main-wrapper');
|
||
if (!main) return;
|
||
const width = Math.round(main.getBoundingClientRect().width);
|
||
const bucket = width < 560 ? 'phone' : width < 820 ? 'narrow' : width < 1120 ? 'compact' : width < 1480 ? 'standard' : 'wide';
|
||
main.dataset.layoutWidth = bucket;
|
||
main.style.setProperty('--workspace-width', `${width}px`);
|
||
document.querySelectorAll('.tab-content').forEach(section => {
|
||
section.dataset.layoutWidth = bucket;
|
||
});
|
||
}
|
||
|
||
function setupResponsiveContracts() {
|
||
applyResponsiveContract();
|
||
const main = document.querySelector('.main-wrapper');
|
||
if (main && 'ResizeObserver' in window) {
|
||
const observer = new ResizeObserver(() => window.requestAnimationFrame(applyResponsiveContract));
|
||
observer.observe(main);
|
||
window.addEventListener('beforeunload', () => observer.disconnect(), { once: true });
|
||
} else {
|
||
window.addEventListener('resize', debounce(applyResponsiveContract, 100));
|
||
}
|
||
}
|
||
|
||
const moduleUiState = new Map();
|
||
|
||
function isUiModuleEnabled(id) {
|
||
return !id || moduleUiState.get(id) !== false;
|
||
}
|
||
|
||
function applyModuleVisibility(root = document) {
|
||
root.querySelectorAll?.('[data-module], [data-module-panel]').forEach(element => {
|
||
const moduleId = element.dataset.module || element.dataset.modulePanel;
|
||
const visible = isUiModuleEnabled(moduleId);
|
||
element.classList.toggle('hidden', !visible);
|
||
element.setAttribute('aria-hidden', visible ? 'false' : 'true');
|
||
if ('disabled' in element && !visible) element.disabled = true;
|
||
});
|
||
}
|
||
|
||
function applyModuleNavigation(payload = {}) {
|
||
moduleUiState.clear();
|
||
for (const module of payload.modules || []) moduleUiState.set(module.id, module.enabled === true);
|
||
applyModuleVisibility(document);
|
||
const active = document.querySelector('.tab-content.active');
|
||
const activeButton = active ? document.querySelector(`.nav-item[data-tab="${active.id}"]`) : null;
|
||
if (activeButton?.classList.contains('hidden')) switchToTab('dashboard');
|
||
}
|
||
|
||
async function refreshModuleNavigation() {
|
||
try {
|
||
const payload = await api('GET', '/api/modules/navigation', null, { retry: false, timeoutMs: 10000 });
|
||
applyModuleNavigation(payload);
|
||
return payload;
|
||
} catch (error) {
|
||
console.warn('[Vendoo] Modulnavigation konnte nicht geladen werden:', error.message);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
window.addEventListener('vendoo:modules-changed', () => refreshModuleNavigation());
|
||
|
||
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 {}
|
||
|
||
// Modulstatus immer zuerst laden: Ein deaktiviertes Modul darf weder seine
|
||
// Navigation anzeigen noch den App-Start durch eine gesperrte API blockieren.
|
||
const moduleNavigation = await api('GET', '/api/modules/navigation');
|
||
applyModuleNavigation(moduleNavigation);
|
||
const aiEnabled = isUiModuleEnabled('vendoo.ai');
|
||
|
||
const [platforms, settings, providers, templates] = await Promise.all([
|
||
api('GET', '/api/platforms'),
|
||
api('GET', '/api/settings'),
|
||
aiEnabled ? api('GET', '/api/providers') : Promise.resolve([]),
|
||
api('GET', '/api/templates'),
|
||
]);
|
||
|
||
state.platforms = platforms;
|
||
state.providers = providers;
|
||
state.templates = templates;
|
||
state.platform = settings.default_platform || 'vinted';
|
||
state.aiProvider = settings.default_ai || 'claude';
|
||
state.language = settings.default_language || 'de';
|
||
|
||
renderPlatformButtons();
|
||
renderProviderButtons();
|
||
populateSettingsForm(settings, platforms, providers);
|
||
populateTemplateSelects(platforms);
|
||
renderTemplateList();
|
||
updateTemplateDropdown();
|
||
document.getElementById('language-select').value = state.language;
|
||
|
||
editors.result = initQuill('#result-description-editor', 'Beschreibung wird hier angezeigt...');
|
||
editors.tplNotes = initQuill('#tpl-notes-editor', 'Verkäufernotizen...');
|
||
|
||
setupGeneratorHtmlSync();
|
||
setupHtmlViewSwitches();
|
||
setupMobileUpload();
|
||
|
||
addQuillTooltips();
|
||
|
||
setupTabs();
|
||
setupAnalyticsDashboard();
|
||
setupTodayFlow();
|
||
setupUpload();
|
||
setupImageTools();
|
||
setupGenerate();
|
||
setupGeneratorStudio();
|
||
setupAiPromptModal();
|
||
setupFluxStudio();
|
||
setupMediaLibrary();
|
||
setupImageFactory();
|
||
setupQualityCenter();
|
||
setupExtensions();
|
||
setupCopy();
|
||
setupHistory();
|
||
setupPublish();
|
||
setupTrash();
|
||
setupTemplates();
|
||
setupFeeCalculator();
|
||
setupSettings();
|
||
setupDarkMode();
|
||
setupPhotoEditor();
|
||
setupBackup();
|
||
setupNotifications();
|
||
setupUserMenu();
|
||
setupAdmin();
|
||
loadAnalyticsDashboard();
|
||
updateTrashBadge();
|
||
}
|
||
|
||
// --- API ---
|
||
let _csrfToken = '';
|
||
function getCsrf() {
|
||
if (_csrfToken) return _csrfToken;
|
||
const m = document.cookie.match(/csrf_token=([^;]+)/);
|
||
return m ? m[1] : '';
|
||
}
|
||
async function api(method, url, body, options = {}) {
|
||
const verb = String(method || 'GET').toUpperCase();
|
||
const attempts = verb === 'GET' && options.retry !== false ? 2 : 1;
|
||
const timeoutMs = Number(options.timeoutMs || (verb === 'GET' ? 20000 : 90000));
|
||
const requestId = globalThis.crypto?.randomUUID?.() || `vd-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
let lastError;
|
||
|
||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||
const controller = new AbortController();
|
||
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||
const opts = { method: verb, headers: { 'X-Vendoo-Request-ID': requestId, ...(options.headers || {}) }, credentials: 'same-origin', signal: controller.signal };
|
||
if (body !== undefined && body !== null) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); }
|
||
if (!['GET', 'HEAD'].includes(verb)) opts.headers['X-CSRF-Token'] = getCsrf();
|
||
try {
|
||
const res = await fetch(url, opts);
|
||
if (res.status === 401) { window.location.href = '/login.html'; return; }
|
||
const contentType = res.headers.get('content-type') || '';
|
||
const payload = contentType.includes('application/json')
|
||
? await res.json().catch(() => ({}))
|
||
: await res.text().catch(() => '');
|
||
if (!res.ok) {
|
||
const error = new Error(payload?.error || payload?.message || (typeof payload === 'string' && payload) || res.statusText || 'Fehler');
|
||
error.status = res.status;
|
||
error.requestId = requestId;
|
||
if (attempt < attempts && res.status >= 500) { lastError = error; continue; }
|
||
throw error;
|
||
}
|
||
if (!clientDiagnostics.online) setConnectionState(true, 'Serververbindung wiederhergestellt.');
|
||
return payload;
|
||
} catch (error) {
|
||
lastError = error?.name === 'AbortError' ? new Error(`Zeitüberschreitung nach ${Math.round(timeoutMs / 1000)} Sekunden`) : error;
|
||
lastError.requestId = requestId;
|
||
const retryable = attempt < attempts && (error?.name === 'AbortError' || error instanceof TypeError || Number(error?.status || 0) >= 500);
|
||
if (retryable) { await new Promise(resolve => window.setTimeout(resolve, 250)); continue; }
|
||
recordClientIncident('api', lastError.message, { method: verb, url, requestId, status: lastError.status || 0 });
|
||
if (error instanceof TypeError || error?.name === 'AbortError') setConnectionState(false, `${lastError.message} · Anfrage ${requestId.slice(0, 8)}`);
|
||
throw lastError;
|
||
} finally {
|
||
window.clearTimeout(timer);
|
||
}
|
||
}
|
||
throw lastError || new Error('Anfrage fehlgeschlagen');
|
||
}
|
||
|
||
|
||
function aiModelStatusClass(tone = 'neutral') {
|
||
return `ai-model-status ${tone ? `is-${tone}` : ''}`.trim();
|
||
}
|
||
|
||
function setAiModelStatus(elementId, message, tone = 'neutral') {
|
||
const element = document.getElementById(elementId);
|
||
if (!element) return;
|
||
element.textContent = message;
|
||
element.className = aiModelStatusClass(tone);
|
||
}
|
||
|
||
|
||
function setAiModelJobProgress(elementId, job) {
|
||
const element = document.getElementById(elementId);
|
||
if (!element) return;
|
||
const progress = Math.max(0, Math.min(100, Number(job?.progress || 0)));
|
||
const message = job?.status === 'failed'
|
||
? (job.error_message || 'AI-Bildjob fehlgeschlagen')
|
||
: (job?.progress_message || formatAiJobStatus(job));
|
||
const tone = job?.status === 'failed' ? 'danger' : job?.status === 'completed' ? 'success' : 'working';
|
||
element.className = `${aiModelStatusClass(tone)} ai-job-progress-card`;
|
||
element.innerHTML = `
|
||
<div class="ai-job-progress-head"><strong>${escapeHtml(message)}</strong><span>${progress}%</span></div>
|
||
<div class="ai-job-progress-track"><div style="width:${progress}%"></div></div>
|
||
<small>${job?.status === 'queued' ? 'Warteschlange' : job?.status === 'running' ? 'Lokale Verarbeitung läuft' : job?.status === 'completed' ? 'Abgeschlossen' : 'Aktion beendet'}</small>
|
||
`;
|
||
}
|
||
|
||
function getGeneratorAiModelPayload() {
|
||
return {
|
||
photos: [...state.photos],
|
||
platform: state.platform,
|
||
title: document.getElementById('result-title')?.value || state.currentListing?.title || '',
|
||
category: metaCategoryPicker?.getValue?.() || state.currentListing?.category || '',
|
||
brand: document.getElementById('meta-brand')?.value || state.currentListing?.brand || '',
|
||
color: document.getElementById('meta-color')?.value || state.currentListing?.color || '',
|
||
size: document.getElementById('meta-size')?.value || state.currentListing?.size || '',
|
||
condition: document.getElementById('meta-condition')?.value || state.currentListing?.condition || '',
|
||
seller_notes: document.getElementById('generator-seller-notes')?.value || state.currentListing?.seller_notes || '',
|
||
mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay',
|
||
preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed',
|
||
variants: parseInt(document.getElementById('generator-ai-model-variants')?.value || '1', 10),
|
||
};
|
||
}
|
||
|
||
function buildAiModelCardHtml(items, scope) {
|
||
const prefix = scope === 'detail' ? 'detail' : 'generator';
|
||
return `
|
||
<div class="ai-model-grid-head">
|
||
<strong>${items.length} sichere Bildvariante${items.length === 1 ? '' : 'n'}</strong>
|
||
<button type="button" class="small-btn" data-ai-model-add-all="${scope}">Alle zu Fotos hinzufügen</button>
|
||
</div>
|
||
<div class="ai-model-grid-cards">
|
||
${items.map((photo, index) => `
|
||
<article class="ai-model-shot-card">
|
||
<img src="${escapeHtml(photoUrl(photo.image_path || photo.filename))}" alt="Produktbild-Variante ${index + 1}" loading="lazy">
|
||
<div class="ai-model-shot-copy">
|
||
<strong>Variante ${photo.variant_index || index + 1}</strong>
|
||
<span>${escapeHtml((photo.mode || 'flatlay').replace('_', ' ').toUpperCase())} · ${escapeHtml((photo.preset || 'mixed').toUpperCase())}</span>
|
||
</div>
|
||
<button type="button" class="small-btn" data-ai-model-add-one="${escapeHtml(photo.image_path || photo.filename)}" data-ai-model-scope="${prefix}">Zu Fotos hinzufügen</button>
|
||
</article>`).join('')}
|
||
</div>`;
|
||
}
|
||
|
||
|
||
function renderAiModelGrid(scope, items = []) {
|
||
const grid = document.getElementById(scope === 'detail' ? 'detail-ai-model-grid' : 'generator-ai-model-grid');
|
||
if (!grid) return;
|
||
if (!items.length) {
|
||
grid.innerHTML = '';
|
||
grid.classList.add('hidden');
|
||
return;
|
||
}
|
||
grid.innerHTML = buildAiModelCardHtml(items, scope);
|
||
grid.classList.remove('hidden');
|
||
|
||
const addOne = filename => {
|
||
if (scope === 'detail') {
|
||
if (!detailEditorState) return;
|
||
if (!detailEditorState.photos.includes(filename)) detailEditorState.photos.push(filename);
|
||
detailEditorState.activePhotoIndex = detailEditorState.photos.length - 1;
|
||
renderDetailPhotos();
|
||
} else {
|
||
if (!state.photos.includes(filename)) state.photos.push(filename);
|
||
renderPhotoPreviews();
|
||
updateGenerateButton();
|
||
}
|
||
toast('AI-Model-Foto hinzugefügt');
|
||
};
|
||
|
||
grid.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => addOne(button.dataset.aiModelAddOne)));
|
||
grid.querySelector('[data-ai-model-add-all]')?.addEventListener('click', () => {
|
||
items.forEach(photo => addOne(photo.image_path || photo.filename));
|
||
toast('Alle AI-Model-Fotos hinzugefügt');
|
||
});
|
||
}
|
||
|
||
async function pollAiModelJob(jobId, onProgress) {
|
||
const started = Date.now();
|
||
while (Date.now() - started < 360000) {
|
||
const job = await api('GET', `/api/ai-model-photos/jobs/${jobId}`);
|
||
if (typeof onProgress === 'function') onProgress(job);
|
||
if (job.status === 'completed' || job.status === 'failed') return job;
|
||
await new Promise(resolve => setTimeout(resolve, 1800));
|
||
}
|
||
throw new Error('Zeitüberschreitung bei der AI-Bildgenerierung');
|
||
}
|
||
|
||
function formatAiJobStatus(job) {
|
||
if (!job) return 'AI-Bildjob wird vorbereitet …';
|
||
if (job.status === 'queued') return job.progress_message || 'AI-Bildjob steht in der Warteschlange …';
|
||
if (job.status === 'running') return job.progress_message || `AI-Bilder werden erzeugt … ${job.progress || 1}%`;
|
||
if (job.status === 'failed') return job.error_message || 'AI-Bildjob fehlgeschlagen';
|
||
return job.progress_message || 'AI-Bildjob abgeschlossen';
|
||
}
|
||
|
||
|
||
function getAiPromptDraft(scope = 'generator') {
|
||
if (!state.aiPromptDrafts[scope]) state.aiPromptDrafts[scope] = { customPrompt: '', preserveLogos: true };
|
||
return state.aiPromptDrafts[scope];
|
||
}
|
||
|
||
function getAiPromptContext(scope = 'generator') {
|
||
if (scope === 'detail') {
|
||
return {
|
||
mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay',
|
||
preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed',
|
||
variants: document.getElementById('detail-ai-model-variants')?.value || '1',
|
||
};
|
||
}
|
||
return {
|
||
mode: document.getElementById('generator-ai-model-mode')?.value || 'flatlay',
|
||
preset: document.getElementById('generator-ai-model-preset')?.value || 'mixed',
|
||
variants: document.getElementById('generator-ai-model-variants')?.value || '1',
|
||
};
|
||
}
|
||
|
||
function updateAiPromptSummary(scope = 'generator') {
|
||
const ctx = getAiPromptContext(scope);
|
||
const modeLabel = ctx.mode === 'ghost_mannequin' ? 'Ghost Mannequin' : 'Flatlay';
|
||
const lookLabel = ctx.preset === 'mixed' ? 'Mix' : ctx.preset === 'studio' ? 'Studio' : ctx.preset === 'indoor' ? 'Indoor' : 'Outdoor';
|
||
document.getElementById('ai-prompt-mode-chip').textContent = `Modus: ${modeLabel}`;
|
||
document.getElementById('ai-prompt-look-chip').textContent = `Look: ${lookLabel}`;
|
||
document.getElementById('ai-prompt-variants-chip').textContent = `Varianten: ${ctx.variants}`;
|
||
const logoCheck = document.getElementById('ai-prompt-preserve-logos');
|
||
if (logoCheck) {
|
||
logoCheck.disabled = false;
|
||
}
|
||
}
|
||
|
||
function closeAiPromptModal() {
|
||
const modal = document.getElementById('ai-prompt-modal');
|
||
modal?.classList.add('hidden');
|
||
modal?.setAttribute('aria-hidden', 'true');
|
||
document.body.classList.remove('ai-prompt-open');
|
||
}
|
||
|
||
function syncAiPromptDraftFromModal() {
|
||
const scope = state.aiPromptScope || 'generator';
|
||
const draft = getAiPromptDraft(scope);
|
||
draft.customPrompt = document.getElementById('ai-prompt-textarea')?.value || '';
|
||
draft.preserveLogos = document.getElementById('ai-prompt-preserve-logos')?.checked !== false;
|
||
}
|
||
|
||
function openAiPromptModal(scope = 'generator') {
|
||
const modal = document.getElementById('ai-prompt-modal');
|
||
if (!modal) return;
|
||
state.aiPromptScope = scope;
|
||
const draft = getAiPromptDraft(scope);
|
||
document.getElementById('ai-prompt-scope-copy').textContent = scope === 'detail' ? 'Zusatzanweisung für das aktuelle Listing' : 'Zusatzanweisung für den Generator';
|
||
document.getElementById('ai-prompt-textarea').value = draft.customPrompt || '';
|
||
document.getElementById('ai-prompt-preserve-logos').checked = draft.preserveLogos !== false;
|
||
updateAiPromptSummary(scope);
|
||
modal.classList.remove('hidden');
|
||
modal.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('ai-prompt-open');
|
||
setTimeout(() => document.getElementById('ai-prompt-textarea')?.focus(), 30);
|
||
}
|
||
|
||
function setupAiPromptModal() {
|
||
const modal = document.getElementById('ai-prompt-modal');
|
||
if (!modal) return;
|
||
modal.querySelectorAll('[data-ai-prompt-close]').forEach(btn => btn.addEventListener('click', closeAiPromptModal));
|
||
document.getElementById('ai-prompt-textarea')?.addEventListener('input', syncAiPromptDraftFromModal);
|
||
document.getElementById('ai-prompt-preserve-logos')?.addEventListener('change', syncAiPromptDraftFromModal);
|
||
document.getElementById('ai-prompt-reset-btn')?.addEventListener('click', () => {
|
||
const draft = getAiPromptDraft(state.aiPromptScope || 'generator');
|
||
draft.customPrompt = '';
|
||
draft.preserveLogos = true;
|
||
document.getElementById('ai-prompt-textarea').value = '';
|
||
document.getElementById('ai-prompt-preserve-logos').checked = true;
|
||
});
|
||
document.getElementById('ai-prompt-run-btn')?.addEventListener('click', async () => {
|
||
syncAiPromptDraftFromModal();
|
||
const scope = state.aiPromptScope || 'generator';
|
||
closeAiPromptModal();
|
||
if (scope === 'detail') await runDetailAiModelPhotos();
|
||
else await runGeneratorAiModelPhotos();
|
||
});
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && !modal.classList.contains('hidden')) closeAiPromptModal();
|
||
});
|
||
}
|
||
|
||
|
||
|
||
const FLUX_PROMPT_TEMPLATES = {
|
||
product: 'Professionelle Produktfotografie, klares Hauptmotiv, neutraler Studiohintergrund, weiches Licht, realistische Materialien, hochwertige kommerzielle Bildsprache',
|
||
social: 'Modernes Social-Media-Motiv, klarer visueller Fokus, starke aber ausgewogene Komposition, hochwertige Beleuchtung, sofort verständlich',
|
||
editorial: 'Premium Editorial-Fotografie, anspruchsvolle Magazinkomposition, kontrolliertes Licht, elegante Farbwelt, hochwertige realistische Details',
|
||
background: 'Minimalistischer heller Studiohintergrund, weiche natürliche Schatten, dezente Tiefe, sauber, hochwertig, ohne Text und ohne Personen',
|
||
lifestyle: 'Authentische hochwertige Lifestyle-Szene, natürliches Tageslicht, glaubwürdige Umgebung, harmonische Komposition, fotorealistisch',
|
||
banner: 'Breites hochwertiges Werbemotiv, klarer freier Bereich für spätere Texte, kontrollierte Beleuchtung, moderne kommerzielle Gestaltung',
|
||
};
|
||
|
||
const FLUX_PROFILE_PRESETS = {
|
||
fast: { format: '768x768', steps: '4' },
|
||
balanced: { format: '1024x1024', steps: '4' },
|
||
quality: { format: '1024x1024', steps: '6' },
|
||
custom: null,
|
||
};
|
||
|
||
const FLUX_TERMINAL_JOB_STATUSES = new Set(['completed', 'completed_with_errors', 'failed', 'cancelled']);
|
||
const FLUX_ACTIVE_JOB_STATUSES = new Set(['queued', 'running', 'cancelling']);
|
||
|
||
function analyzeFluxPrompt(value = '') {
|
||
const text = String(value || '').trim();
|
||
if (!text) return { level: 'empty', copy: 'Beschreibe zuerst Motiv, Umgebung, Licht und Perspektive.' };
|
||
const words = text.split(/\s+/).filter(Boolean).length;
|
||
const signals = [
|
||
/licht|lighting|beleuchtung/i,
|
||
/hintergrund|background|umgebung|scene|studio/i,
|
||
/kamera|camera|perspektive|perspective|close-up|nahaufnahme|wide shot/i,
|
||
/farbe|color|material|texture|oberfläche/i,
|
||
].filter(pattern => pattern.test(text)).length;
|
||
if (words < 8) return { level: 'weak', copy: 'Noch sehr kurz. Ergänze Motiv, Umgebung, Licht und Bildausschnitt.' };
|
||
if (words < 18 || signals < 2) return { level: 'good', copy: 'Gute Basis. Der Prompt-Assistent kann Komposition und technische Bildsprache ergänzen.' };
|
||
return { level: 'strong', copy: 'Starker Prompt mit ausreichend visueller Führung.' };
|
||
}
|
||
|
||
function refreshFluxPromptQuality() {
|
||
const input = document.getElementById('flux-studio-prompt');
|
||
const box = document.getElementById('flux-prompt-quality');
|
||
const copy = document.getElementById('flux-prompt-quality-copy');
|
||
if (!box || !copy) return;
|
||
const result = analyzeFluxPrompt(input?.value || '');
|
||
box.dataset.level = result.level;
|
||
copy.textContent = result.copy;
|
||
}
|
||
|
||
function optimizeFluxPromptLocally(value = '') {
|
||
let text = String(value || '').trim().replace(/\s+/g, ' ');
|
||
if (!text) return text;
|
||
const additions = [];
|
||
if (!/licht|lighting|beleuchtung/i.test(text)) additions.push('weiches gerichtetes Licht mit realistischen Schatten');
|
||
if (!/kamera|camera|perspektive|perspective|aufnahme|shot/i.test(text)) additions.push('klarer professioneller Bildausschnitt auf Augenhöhe');
|
||
if (!/hintergrund|background|umgebung|scene|studio/i.test(text)) additions.push('ruhige, zum Motiv passende Umgebung');
|
||
if (!/detail|material|texture|oberfläche/i.test(text)) additions.push('realistische Materialien und feine Oberflächendetails');
|
||
if (additions.length) text = `${text.replace(/[,. ]+$/, '')}, ${additions.join(', ')}`;
|
||
return text;
|
||
}
|
||
|
||
function applyFluxProfile(profile, { persist = true } = {}) {
|
||
const normalized = FLUX_PROFILE_PRESETS[profile] ? profile : 'custom';
|
||
const hidden = document.getElementById('flux-studio-profile');
|
||
if (hidden) hidden.value = normalized;
|
||
document.querySelectorAll('[data-flux-profile]').forEach(button => button.classList.toggle('active', button.dataset.fluxProfile === normalized));
|
||
const preset = FLUX_PROFILE_PRESETS[normalized];
|
||
if (preset) {
|
||
const format = document.getElementById('flux-studio-format');
|
||
const steps = document.getElementById('flux-studio-steps');
|
||
if (format) format.value = preset.format;
|
||
if (steps) steps.value = preset.steps;
|
||
}
|
||
if (persist) { try { localStorage.setItem('vendoo-flux-profile', normalized); } catch {} }
|
||
}
|
||
|
||
function setFluxVariantCount(value, { persist = true } = {}) {
|
||
const normalized = [1, 2, 4].includes(Number(value)) ? Number(value) : 1;
|
||
const input = document.getElementById('flux-studio-variants');
|
||
if (input) input.value = String(normalized);
|
||
document.querySelectorAll('[data-flux-variants]').forEach(button => button.classList.toggle('active', Number(button.dataset.fluxVariants) === normalized));
|
||
if (persist) { try { localStorage.setItem('vendoo-flux-variants', String(normalized)); } catch {} }
|
||
}
|
||
|
||
function resetFluxStudioWorkspace({ clearSavedDefaults = false, announce = false } = {}) {
|
||
state.fluxStudioCurrent = null;
|
||
renderFluxStudioPreview(null);
|
||
const variants = document.getElementById('flux-studio-variant-results');
|
||
if (variants) { variants.innerHTML = ''; variants.classList.add('hidden'); }
|
||
|
||
const prompt = document.getElementById('flux-studio-prompt');
|
||
if (prompt) { prompt.value = ''; prompt.dispatchEvent(new Event('input')); }
|
||
const mode = document.getElementById('flux-studio-prompt-mode');
|
||
if (mode) mode.value = 'precise';
|
||
const style = document.getElementById('flux-studio-style');
|
||
if (style) style.value = 'photorealistic';
|
||
const seed = document.getElementById('flux-studio-seed');
|
||
if (seed) seed.value = '42';
|
||
const lock = document.getElementById('flux-studio-seed-lock');
|
||
if (lock) lock.checked = false;
|
||
applyFluxProfile('balanced', { persist: !clearSavedDefaults });
|
||
setFluxVariantCount(1, { persist: !clearSavedDefaults });
|
||
if (clearSavedDefaults) {
|
||
try {
|
||
localStorage.removeItem('vendoo-flux-profile');
|
||
localStorage.removeItem('vendoo-flux-variants');
|
||
} catch {}
|
||
}
|
||
setFluxStudioStatus('Bereit. Noch keine Auswahl.', '');
|
||
setFluxStudioProgress('', false);
|
||
renderFluxStudioHistory();
|
||
if (announce) toast('FLUX Studio wurde auf einen leeren Arbeitsbereich zurückgesetzt. Verlauf und Bilder bleiben erhalten.');
|
||
}
|
||
|
||
function parseFluxFormat() {
|
||
const value = document.getElementById('flux-studio-format')?.value || '768x768';
|
||
const [width, height] = value.split('x').map(Number);
|
||
return { width: width || 768, height: height || 768 };
|
||
}
|
||
|
||
function setFluxStudioStatus(message, tone = '') {
|
||
const status = document.getElementById('flux-studio-status');
|
||
if (!status) return;
|
||
status.textContent = message;
|
||
status.dataset.tone = tone;
|
||
}
|
||
|
||
function setFluxStudioProgress(label = 'Vorbereitung', visible = true, stateName = 'active') {
|
||
const shell = document.getElementById('flux-studio-progress');
|
||
const value = document.getElementById('flux-studio-progress-value');
|
||
const text = document.getElementById('flux-studio-progress-label');
|
||
shell?.classList.toggle('hidden', !visible);
|
||
if (shell) shell.dataset.state = stateName;
|
||
if (value) value.textContent = stateName === 'done' ? 'Fertig' : stateName === 'error' ? 'Fehler' : stateName === 'cancelled' ? 'Abgebrochen' : 'Aktive Phase';
|
||
if (text) text.textContent = label || 'FLUX arbeitet …';
|
||
}
|
||
|
||
function formatFluxDate(value) {
|
||
if (!value) return '–';
|
||
const normalized = String(value).includes('T') ? String(value) : `${String(value).replace(' ', 'T')}Z`;
|
||
const date = new Date(normalized);
|
||
return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
|
||
}
|
||
|
||
function formatFluxDuration(ms) {
|
||
const seconds = Math.max(0, Math.round(Number(ms || 0) / 1000));
|
||
if (seconds < 60) return `${seconds} s`;
|
||
return `${Math.floor(seconds / 60)} min ${seconds % 60} s`;
|
||
}
|
||
|
||
function fluxStatusLabel(status) {
|
||
return ({
|
||
queued: 'Wartend', running: 'Läuft', cancelling: 'Wird abgebrochen', completed: 'Erfolgreich',
|
||
completed_with_errors: 'Teilweise fertig', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen',
|
||
})[status] || status || 'Unbekannt';
|
||
}
|
||
|
||
async function refreshFluxStudioEngineStatus() {
|
||
const chip = document.getElementById('flux-studio-engine-chip');
|
||
try {
|
||
const status = await api('GET', '/api/local-image/status');
|
||
const ready = !!status.reachable;
|
||
if (chip) {
|
||
chip.textContent = status.engine_label || (ready ? 'Aktiv und bereit' : 'Nicht bereit');
|
||
chip.className = `settings-status-chip ${ready ? 'is-ready' : ['stopped','starting'].includes(status.engine_state) ? 'is-warning' : 'is-disabled'}`;
|
||
}
|
||
document.getElementById('flux-studio-generate')?.toggleAttribute('disabled', !ready || state.fluxStudioBusy);
|
||
return status;
|
||
} catch (error) {
|
||
if (chip) { chip.textContent = 'Status nicht verfügbar'; chip.className = 'settings-status-chip is-disabled'; }
|
||
return null;
|
||
}
|
||
}
|
||
|
||
async function waitForFluxEngineReady({ statusElement = null, timeoutMs = 190000 } = {}) {
|
||
const startedAt = Date.now();
|
||
while (Date.now() - startedAt < timeoutMs) {
|
||
const status = await refreshFluxStudioEngineStatus();
|
||
if (status?.reachable) {
|
||
if (statusElement) statusElement.textContent = 'Lokaler FLUX/ComfyUI-Server ist aktiv und bereit.';
|
||
return status;
|
||
}
|
||
const runtime = status?.runtime_state || {};
|
||
const elapsed = Math.max(0, Math.round((Date.now() - startedAt) / 1000));
|
||
const detail = runtime.message || status?.message || 'ComfyUI wird initialisiert.';
|
||
if (statusElement) statusElement.textContent = `FLUX startet seit ${elapsed}s · ${detail}`;
|
||
setFluxStudioStatus(`FLUX startet seit ${elapsed}s · ${detail}`, 'working');
|
||
if (runtime.status === 'failed' || status?.engine_state === 'failed') {
|
||
const tail = [...(runtime.stderr_tail || []), ...(runtime.stdout_tail || [])].filter(Boolean).slice(-6).join(' | ');
|
||
throw new Error(runtime.error || tail || runtime.message || 'ComfyUI wurde während des Starts beendet.');
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
}
|
||
throw new Error('ComfyUI war nach 190 Sekunden noch nicht erreichbar. Prüfe die Runtime-Protokolle in Vendoo/logs.');
|
||
}
|
||
|
||
function fluxVariantToImage(job, variant) {
|
||
if (!variant?.image_path) return null;
|
||
return {
|
||
id: variant.image_id,
|
||
job_id: job.id,
|
||
variant_index: variant.variant_index,
|
||
prompt: job.prompt,
|
||
final_prompt: job.final_prompt,
|
||
image_path: variant.image_path,
|
||
public_url: variant.public_url,
|
||
width: job.width,
|
||
height: job.height,
|
||
seed: variant.seed,
|
||
steps: job.steps,
|
||
style: job.style,
|
||
profile: job.profile,
|
||
prompt_mode: job.prompt_mode,
|
||
duration_ms: variant.duration_ms,
|
||
favorite: variant.favorite,
|
||
parameters: {
|
||
width: job.width, height: job.height, steps: job.steps, style: job.style, profile: job.profile,
|
||
prompt_mode: job.prompt_mode, variants: job.variants_count, seed_lock: job.seed_lock,
|
||
},
|
||
};
|
||
}
|
||
|
||
function renderFluxVariantResults(job) {
|
||
const container = document.getElementById('flux-studio-variant-results');
|
||
if (!container) return;
|
||
const completed = (job?.variants || []).filter(variant => variant.image_path);
|
||
container.classList.toggle('hidden', !completed.length);
|
||
if (!completed.length) { container.innerHTML = ''; return; }
|
||
container.innerHTML = completed.map(variant => {
|
||
const item = fluxVariantToImage(job, variant);
|
||
const active = Number(state.fluxStudioCurrent?.id) === Number(item.id);
|
||
return `<button type="button" class="flux-variant-tile ${active ? 'active' : ''}" data-flux-variant-image="${escapeHtml(String(item.id))}">
|
||
<img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="Variante ${variant.variant_index}">
|
||
<span>Variante ${variant.variant_index}</span><small>Seed ${variant.seed}</small>
|
||
</button>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-flux-variant-image]').forEach(button => button.addEventListener('click', () => {
|
||
const variant = completed.find(entry => String(entry.image_id) === button.dataset.fluxVariantImage);
|
||
const item = fluxVariantToImage(job, variant);
|
||
if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); }
|
||
}));
|
||
}
|
||
|
||
function renderFluxStudioPreview(item) {
|
||
state.fluxStudioCurrent = item || null;
|
||
const image = document.getElementById('flux-studio-preview-image');
|
||
const empty = document.getElementById('flux-studio-preview-empty');
|
||
const actions = document.getElementById('flux-studio-preview-actions');
|
||
const meta = document.getElementById('flux-studio-preview-meta');
|
||
const download = document.getElementById('flux-studio-download');
|
||
const favorite = document.getElementById('flux-studio-favorite');
|
||
if (!item?.image_path) {
|
||
image?.classList.add('hidden');
|
||
empty?.classList.remove('hidden');
|
||
actions?.classList.add('hidden');
|
||
if (meta) meta.textContent = 'Noch kein Bild';
|
||
return;
|
||
}
|
||
if (image) {
|
||
image.src = item.public_url || photoUrl(item.image_path);
|
||
image.alt = item.prompt ? `FLUX: ${item.prompt}` : 'FLUX-Vorschau';
|
||
image.classList.remove('hidden');
|
||
}
|
||
empty?.classList.add('hidden');
|
||
actions?.classList.remove('hidden');
|
||
if (meta) meta.textContent = `${item.width || '–'} × ${item.height || '–'} · Seed ${item.seed ?? '–'} · ${item.steps || '–'} Schritte${item.duration_ms ? ` · ${formatFluxDuration(item.duration_ms)}` : ''}`;
|
||
if (download) {
|
||
download.href = item.public_url || photoUrl(item.image_path);
|
||
download.download = `vendoo-flux-${item.id || Date.now()}.${String(item.image_path).split('.').pop() || 'png'}`;
|
||
}
|
||
if (favorite) favorite.textContent = item.favorite ? '★ Favorit' : '☆ Favorit';
|
||
}
|
||
|
||
function renderFluxStudioHistory() {
|
||
const container = document.getElementById('flux-studio-history');
|
||
if (!container) return;
|
||
if (!state.fluxStudioHistory.length) {
|
||
container.innerHTML = '<div class="empty-state">Noch keine passenden FLUX-Bilder gespeichert.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = state.fluxStudioHistory.map(item => `<article class="flux-history-card ${state.fluxStudioCurrent?.id === item.id ? 'active' : ''}">
|
||
<button type="button" class="flux-history-image" data-flux-select="${item.id}"><img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="${escapeHtml(item.prompt || 'FLUX-Bild')}"><span>${item.favorite ? '★' : ''}</span></button>
|
||
<div><strong title="${escapeHtml(item.prompt || '')}">${escapeHtml(item.prompt || 'FLUX-Bild')}</strong><small>${item.width} × ${item.height} · Seed ${item.seed ?? '–'} · ${formatFluxDate(item.created_at)}</small></div>
|
||
<div class="flux-history-actions"><button type="button" class="small-btn" data-flux-favorite="${item.id}">${item.favorite ? '★' : '☆'}</button><button type="button" class="small-btn" data-flux-similar="${item.id}">Ähnlich</button><button type="button" class="small-btn" data-flux-edit="${item.id}">Bearbeiten</button><button type="button" class="small-btn danger" data-flux-delete="${item.id}">Löschen</button></div>
|
||
</article>`).join('');
|
||
container.querySelectorAll('[data-flux-select]').forEach(button => button.addEventListener('click', () => {
|
||
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSelect);
|
||
if (item) { renderFluxStudioPreview(item); renderFluxStudioHistory(); }
|
||
}));
|
||
container.querySelectorAll('[data-flux-favorite]').forEach(button => button.addEventListener('click', async () => {
|
||
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxFavorite);
|
||
if (item) await toggleFluxFavorite(item);
|
||
}));
|
||
container.querySelectorAll('[data-flux-similar]').forEach(button => button.addEventListener('click', () => {
|
||
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxSimilar);
|
||
if (item) createSimilarFluxJob(item);
|
||
}));
|
||
container.querySelectorAll('[data-flux-edit]').forEach(button => button.addEventListener('click', () => {
|
||
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxEdit);
|
||
if (item) { renderFluxStudioPreview(item); openPhotoEditor(0, 'flux'); }
|
||
}));
|
||
container.querySelectorAll('[data-flux-delete]').forEach(button => button.addEventListener('click', async () => {
|
||
const item = state.fluxStudioHistory.find(entry => String(entry.id) === button.dataset.fluxDelete);
|
||
if (item) await deleteFluxStudioImage(item);
|
||
}));
|
||
}
|
||
|
||
function renderFluxHistoryPagination() {
|
||
const pagination = state.fluxHistoryPagination || {};
|
||
const page = Number(pagination.page || 1);
|
||
const pages = Number(pagination.total_pages || 1);
|
||
const total = Number(pagination.total || 0);
|
||
document.getElementById('flux-history-page-info')?.replaceChildren(document.createTextNode(`Seite ${page} von ${pages} · ${total} Bilder`));
|
||
const prev = document.getElementById('flux-history-prev');
|
||
const next = document.getElementById('flux-history-next');
|
||
if (prev) prev.disabled = page <= 1;
|
||
if (next) next.disabled = page >= pages;
|
||
}
|
||
|
||
async function loadFluxStudioHistory({ silent = false } = {}) {
|
||
const container = document.getElementById('flux-studio-history');
|
||
if (!silent && container && !state.fluxStudioHistory.length) container.innerHTML = '<div class="empty-state">FLUX-Bibliothek wird geladen …</div>';
|
||
try {
|
||
const favorites = document.getElementById('flux-studio-favorites-only')?.checked ? '1' : '0';
|
||
const result = await api('GET', `/api/flux-studio/images?page=${state.fluxHistoryPage}&page_size=${state.fluxHistoryPageSize}&favorites=${favorites}`);
|
||
state.fluxStudioHistory = result.items || [];
|
||
state.fluxHistoryPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxStudioHistory.length };
|
||
state.fluxHistoryPage = Number(state.fluxHistoryPagination.page || 1);
|
||
if (state.fluxStudioCurrent) {
|
||
const refreshed = state.fluxStudioHistory.find(item => item.id === state.fluxStudioCurrent.id);
|
||
if (refreshed) renderFluxStudioPreview(refreshed);
|
||
else renderFluxStudioPreview(null);
|
||
}
|
||
renderFluxStudioHistory();
|
||
renderFluxHistoryPagination();
|
||
} catch (error) {
|
||
if (container) container.innerHTML = `<div class="empty-state">Bibliothek konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function toggleFluxFavorite(item = state.fluxStudioCurrent) {
|
||
if (!item?.id) return;
|
||
try {
|
||
const updated = await api('PATCH', `/api/flux-studio/images/${item.id}/favorite`, { favorite: !item.favorite });
|
||
state.fluxStudioHistory = state.fluxStudioHistory.map(entry => entry.id === updated.id ? updated : entry);
|
||
if (state.fluxStudioCurrent?.id === updated.id) renderFluxStudioPreview(updated);
|
||
await loadFluxStudioHistory({ silent: true });
|
||
toast(updated.favorite ? 'Als Favorit gespeichert.' : 'Favorit entfernt.');
|
||
} catch (error) { toast(`Favorit konnte nicht geändert werden: ${error.message}`); }
|
||
}
|
||
|
||
async function deleteFluxStudioImage(item = state.fluxStudioCurrent) {
|
||
if (!item?.id) return;
|
||
if (!window.confirm('Dieses FLUX-Bild wirklich dauerhaft löschen?')) return;
|
||
try {
|
||
await api('DELETE', `/api/flux-studio/images/${item.id}`);
|
||
if (state.fluxStudioCurrent?.id === item.id) renderFluxStudioPreview(null);
|
||
await loadFluxStudioHistory({ silent: true });
|
||
toast('FLUX-Bild gelöscht.');
|
||
} catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); }
|
||
}
|
||
|
||
function getFluxStudioPayload() {
|
||
const { width, height } = parseFluxFormat();
|
||
return {
|
||
prompt: document.getElementById('flux-studio-prompt')?.value.trim() || '',
|
||
width,
|
||
height,
|
||
seed: Number(document.getElementById('flux-studio-seed')?.value || 42),
|
||
steps: Number(document.getElementById('flux-studio-steps')?.value || 4),
|
||
style: document.getElementById('flux-studio-style')?.value || 'none',
|
||
profile: document.getElementById('flux-studio-profile')?.value || 'balanced',
|
||
prompt_mode: document.getElementById('flux-studio-prompt-mode')?.value || 'precise',
|
||
assistant: true,
|
||
variants: Number(document.getElementById('flux-studio-variants')?.value || 1),
|
||
seed_lock: !!document.getElementById('flux-studio-seed-lock')?.checked,
|
||
};
|
||
}
|
||
|
||
function applyFluxJobToForm(jobOrImage) {
|
||
const item = jobOrImage || {};
|
||
const params = item.parameters || {};
|
||
const prompt = document.getElementById('flux-studio-prompt');
|
||
if (prompt && item.prompt) { prompt.value = item.prompt; prompt.dispatchEvent(new Event('input')); }
|
||
const format = `${item.width || params.width || 768}x${item.height || params.height || 768}`;
|
||
const formatSelect = document.getElementById('flux-studio-format');
|
||
if (formatSelect && [...formatSelect.options].some(option => option.value === format)) formatSelect.value = format;
|
||
const steps = document.getElementById('flux-studio-steps');
|
||
if (steps && (item.steps || params.steps)) steps.value = String(item.steps || params.steps);
|
||
const style = document.getElementById('flux-studio-style'); if (style) style.value = item.style || params.style || 'none';
|
||
const mode = document.getElementById('flux-studio-prompt-mode'); if (mode) mode.value = item.prompt_mode || params.prompt_mode || 'precise';
|
||
const seed = document.getElementById('flux-studio-seed'); if (seed && Number.isFinite(Number(item.seed ?? item.seed_base))) seed.value = String(item.seed ?? item.seed_base);
|
||
const lock = document.getElementById('flux-studio-seed-lock'); if (lock) lock.checked = !!(item.seed_lock ?? params.seed_lock);
|
||
setFluxVariantCount(item.variants_count || params.variants || 1);
|
||
applyFluxProfile(item.profile || params.profile || 'custom');
|
||
}
|
||
|
||
async function watchFluxJob(jobId) {
|
||
if (!jobId || state.fluxStudioWatchers.has(jobId)) return;
|
||
state.fluxStudioWatchers.set(jobId, true);
|
||
try {
|
||
const deadline = Date.now() + 12 * 60 * 1000;
|
||
while (Date.now() < deadline) {
|
||
const job = await api('GET', `/api/flux-prompt/jobs/${encodeURIComponent(jobId)}`);
|
||
const activeVariant = (job.variants || []).find(variant => variant.status === 'running') || (job.variants || []).find(variant => variant.status === 'queued');
|
||
const label = activeVariant?.message || job.message || fluxStatusLabel(job.status);
|
||
const stateName = job.status === 'failed' ? 'error' : job.status === 'cancelled' ? 'cancelled' : FLUX_TERMINAL_JOB_STATUSES.has(job.status) ? 'done' : 'active';
|
||
setFluxStudioProgress(label, true, stateName);
|
||
setFluxStudioStatus(label, stateName === 'error' ? 'error' : stateName === 'done' ? 'success' : 'working');
|
||
const images = (job.variants || []).filter(variant => variant.image_path);
|
||
if (images.length) {
|
||
const selected = fluxVariantToImage(job, images[0]);
|
||
if (selected) renderFluxStudioPreview(selected);
|
||
renderFluxVariantResults(job);
|
||
}
|
||
await loadFluxJobs({ silent: true });
|
||
if (FLUX_TERMINAL_JOB_STATUSES.has(job.status)) {
|
||
await loadFluxStudioHistory();
|
||
await refreshFluxRuntimeInfo({ silent: true });
|
||
if (job.status === 'failed') toast(`FLUX-Auftrag fehlgeschlagen: ${job.error || 'Unbekannter Fehler'}`);
|
||
else if (job.status === 'cancelled') toast('FLUX-Auftrag wurde abgebrochen.');
|
||
else toast(job.status === 'completed_with_errors' ? 'FLUX-Auftrag teilweise abgeschlossen.' : 'FLUX-Auftrag abgeschlossen.');
|
||
return job;
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 1400));
|
||
}
|
||
setFluxStudioStatus('Der Auftrag läuft weiter im Job-Center.', 'working');
|
||
} catch (error) {
|
||
setFluxStudioStatus(error.message, 'error');
|
||
} finally {
|
||
state.fluxStudioWatchers.delete(jobId);
|
||
}
|
||
}
|
||
|
||
async function runFluxStudioGeneration(payloadOverride = null) {
|
||
if (state.fluxStudioBusy) return;
|
||
const payload = payloadOverride || getFluxStudioPayload();
|
||
if (String(payload.prompt || '').trim().length < 3) return toast('Bitte einen aussagekräftigen Prompt eingeben.');
|
||
const button = document.getElementById('flux-studio-generate');
|
||
state.fluxStudioBusy = true;
|
||
if (button) button.disabled = true;
|
||
setFluxStudioProgress('Auftrag wird dauerhaft gespeichert …', true, 'active');
|
||
setFluxStudioStatus('FLUX-Auftrag wird erstellt …', 'working');
|
||
try {
|
||
const queued = await api('POST', '/api/flux-prompt/jobs', payload);
|
||
setFluxStudioStatus(`${queued.variants_count} Variante${queued.variants_count === 1 ? '' : 'n'} wurde${queued.variants_count === 1 ? '' : 'n'} eingereiht.`, 'success');
|
||
await loadFluxJobs({ silent: true });
|
||
watchFluxJob(queued.id);
|
||
return queued;
|
||
} catch (error) {
|
||
setFluxStudioStatus(error.message, 'error');
|
||
setFluxStudioProgress('Auftrag konnte nicht erstellt werden.', true, 'error');
|
||
toast(`FLUX-Fehler: ${error.message}`);
|
||
return null;
|
||
} finally {
|
||
state.fluxStudioBusy = false;
|
||
if (button) button.disabled = false;
|
||
refreshFluxStudioEngineStatus();
|
||
}
|
||
}
|
||
|
||
async function createSimilarFluxJob(item = state.fluxStudioCurrent) {
|
||
if (!item?.prompt) return;
|
||
applyFluxJobToForm(item);
|
||
const seed = Math.floor(Math.random() * 2147483647);
|
||
const seedInput = document.getElementById('flux-studio-seed'); if (seedInput) seedInput.value = String(seed);
|
||
toast('Prompt und Parameter wurden übernommen; der neue Basis-Seed erzeugt ähnliche Varianten.');
|
||
await runFluxStudioGeneration();
|
||
}
|
||
|
||
function renderFluxJobSummary() {
|
||
const container = document.getElementById('flux-job-summary');
|
||
if (!container) return;
|
||
const summary = state.fluxJobSummary || {};
|
||
const jobs = summary.jobs || {};
|
||
const variants = summary.variants || {};
|
||
document.getElementById('flux-job-archive-count')?.replaceChildren(document.createTextNode(String(summary.archived_total || 0)));
|
||
container.innerHTML = state.fluxJobArchiveMode
|
||
? `<div><span>Archivierte Aufträge</span><strong>${summary.archived_total || 0}</strong></div>
|
||
<div><span>Auf dieser Seite</span><strong>${state.fluxJobs.length}</strong></div>
|
||
<div><span>Seiten</span><strong>${state.fluxJobPagination.total_pages || 1}</strong></div>
|
||
<div><span>Bilder bleiben erhalten</span><strong>Ja</strong></div>`
|
||
: `<div><span>Aktive Worker</span><strong>${summary.active_workers || 0} / ${summary.max_parallel || 1}</strong></div>
|
||
<div><span>Wartende Varianten</span><strong>${variants.queued || 0}</strong></div>
|
||
<div><span>Laufende Varianten</span><strong>${variants.running || 0}</strong></div>
|
||
<div><span>Fertige Aufträge</span><strong>${(jobs.completed || 0) + (jobs.completed_with_errors || 0)}</strong></div>`;
|
||
}
|
||
|
||
function updateFluxJobToolbar() {
|
||
const count = state.fluxJobSelected.size;
|
||
const archiveMode = state.fluxJobArchiveMode;
|
||
document.getElementById('flux-job-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`));
|
||
const selectPage = document.getElementById('flux-job-select-page');
|
||
if (selectPage) {
|
||
const selectedOnPage = state.fluxJobs.filter(job => state.fluxJobSelected.has(job.id)).length;
|
||
selectPage.checked = state.fluxJobs.length > 0 && selectedOnPage === state.fluxJobs.length;
|
||
selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.fluxJobs.length;
|
||
}
|
||
const archive = document.getElementById('flux-job-archive-selected');
|
||
const restore = document.getElementById('flux-job-restore-selected');
|
||
const remove = document.getElementById('flux-job-delete-selected');
|
||
const removeAll = document.getElementById('flux-job-delete-all-archive');
|
||
archive?.classList.toggle('hidden', archiveMode);
|
||
restore?.classList.toggle('hidden', !archiveMode);
|
||
remove?.classList.toggle('hidden', !archiveMode);
|
||
removeAll?.classList.toggle('hidden', !archiveMode);
|
||
if (archive) archive.disabled = count === 0;
|
||
if (restore) restore.disabled = count === 0;
|
||
if (remove) remove.disabled = count === 0;
|
||
document.getElementById('flux-job-clear-queue')?.classList.toggle('hidden', archiveMode);
|
||
document.getElementById('flux-job-filter')?.classList.toggle('hidden', archiveMode);
|
||
document.getElementById('flux-job-view-active')?.classList.toggle('active', !archiveMode);
|
||
document.getElementById('flux-job-view-archive')?.classList.toggle('active', archiveMode);
|
||
}
|
||
|
||
function renderFluxJobPagination() {
|
||
const pagination = state.fluxJobPagination || {};
|
||
const page = Number(pagination.page || 1);
|
||
const pages = Number(pagination.total_pages || 1);
|
||
const total = Number(pagination.total || 0);
|
||
const info = document.getElementById('flux-job-page-info');
|
||
if (info) info.textContent = `Seite ${page} von ${pages} · ${total} Auftrag${total === 1 ? '' : 'e'}`;
|
||
const prev = document.getElementById('flux-job-prev');
|
||
const next = document.getElementById('flux-job-next');
|
||
if (prev) prev.disabled = page <= 1;
|
||
if (next) next.disabled = page >= pages;
|
||
}
|
||
|
||
async function archiveFluxJobs(ids) {
|
||
if (!ids.length) return;
|
||
const result = await api('POST', '/api/flux-prompt/jobs/archive', { ids });
|
||
state.fluxJobSelected.clear();
|
||
toast(`${result.archived || 0} Auftrag/Aufträge archiviert${result.skipped ? ` · ${result.skipped} aktiv oder nicht archiviert` : ''}.`);
|
||
await loadFluxJobs();
|
||
}
|
||
|
||
async function restoreFluxJobs(ids) {
|
||
if (!ids.length) return;
|
||
const result = await api('POST', '/api/flux-prompt/jobs/restore', { ids });
|
||
state.fluxJobSelected.clear();
|
||
toast(`${result.restored || 0} Auftrag/Aufträge wiederhergestellt.`);
|
||
await loadFluxJobs();
|
||
}
|
||
|
||
async function deleteArchivedFluxJobs(ids = [], all = false) {
|
||
const message = all
|
||
? 'Das komplette FLUX-Auftragsarchiv endgültig löschen? Die erzeugten Bilder bleiben in der Bildergalerie erhalten.'
|
||
: `${ids.length} archivierte Auftrag/Aufträge endgültig löschen? Die erzeugten Bilder bleiben erhalten.`;
|
||
if (!window.confirm(message)) return;
|
||
const result = await api('DELETE', `/api/flux-prompt/jobs/archive${all ? '?all=1' : ''}`, { ids });
|
||
state.fluxJobSelected.clear();
|
||
toast(`${result.deleted || 0} archivierte Auftrag/Aufträge gelöscht.`);
|
||
await loadFluxJobs();
|
||
}
|
||
|
||
function renderFluxJobList() {
|
||
const container = document.getElementById('flux-job-list');
|
||
if (!container) return;
|
||
const jobs = state.fluxJobs;
|
||
if (!jobs.length) {
|
||
container.innerHTML = `<div class="empty-state">${state.fluxJobArchiveMode ? 'Das Auftragsarchiv ist leer.' : 'Keine Aufträge für diesen Filter.'}</div>`;
|
||
updateFluxJobToolbar();
|
||
return;
|
||
}
|
||
container.innerHTML = jobs.map(job => {
|
||
const variants = job.variants || [];
|
||
const expanded = state.fluxJobExpanded.has(job.id);
|
||
const selected = state.fluxJobSelected.has(job.id);
|
||
const thumbs = variants.map(variant => variant.image_path
|
||
? `<button type="button" class="flux-job-thumb" data-flux-job-image="${job.id}:${variant.variant_index}" title="Variante ${variant.variant_index} · Seed ${variant.seed}${variant.prompt_id ? ` · Prompt-ID ${escapeHtml(variant.prompt_id)}` : ''}"><img src="${escapeHtml(variant.public_url || photoUrl(variant.image_path))}" alt="Variante ${variant.variant_index}"><span>${variant.variant_index}</span><small>Seed ${variant.seed}</small></button>`
|
||
: `<div class="flux-job-thumb is-placeholder" title="${escapeHtml(variant.message || '')}"><span>${variant.variant_index}</span><small>${escapeHtml(fluxStatusLabel(variant.status))} · Seed ${variant.seed}</small></div>`).join('');
|
||
const ledger = variants.map(variant => `<div><b>V${variant.variant_index}</b><span>Seed ${variant.seed}</span><span>${escapeHtml(variant.phase || variant.status)}</span>${variant.prompt_id ? `<code title="${escapeHtml(variant.prompt_id)}">${escapeHtml(String(variant.prompt_id).slice(0, 12))}${String(variant.prompt_id).length > 12 ? '…' : ''}</code>` : '<code>Noch keine Prompt-ID</code>'}</div>`).join('');
|
||
const canCancel = FLUX_ACTIVE_JOB_STATUSES.has(job.status);
|
||
const canRetry = ['failed', 'cancelled', 'completed_with_errors'].includes(job.status) && !state.fluxJobArchiveMode;
|
||
const canArchive = !state.fluxJobArchiveMode && !canCancel;
|
||
return `<article class="flux-job-card${expanded ? ' is-expanded' : ' is-collapsed'}${selected ? ' is-selected' : ''}" data-status="${escapeHtml(job.status)}">
|
||
<div class="flux-job-card-header">
|
||
<label class="flux-job-card-select"><input type="checkbox" data-flux-job-select="${job.id}" ${selected ? 'checked' : ''} aria-label="Auftrag auswählen"></label>
|
||
<button type="button" class="flux-job-card-toggle" data-flux-job-toggle="${job.id}" aria-expanded="${expanded}">
|
||
<span class="flux-job-card-toggle-main"><span class="flux-job-status is-${escapeHtml(job.status)}">${escapeHtml(fluxStatusLabel(job.status))}</span><strong class="flux-job-prompt" title="${escapeHtml(job.prompt)}">${escapeHtml(job.prompt)}</strong></span>
|
||
<span class="flux-job-card-toggle-meta">${formatFluxDate(job.archived_at || job.created_at)}${job.elapsed_ms ? ` · ${formatFluxDuration(job.elapsed_ms)}` : ''}<i aria-hidden="true">⌄</i></span>
|
||
</button>
|
||
</div>
|
||
<div class="flux-job-card-body">
|
||
<div class="flux-job-card-main"><div class="flux-job-params"><span>${job.width} × ${job.height}</span><span>${job.steps} Schritte</span><span>${escapeHtml(job.style)}</span><span>${job.variants_count} Variante${job.variants_count === 1 ? '' : 'n'}</span><span>Seed ${job.seed_base}${job.seed_lock ? ' · Lock' : ''}</span></div><p>${escapeHtml(job.message || '')}${job.error ? `<br><b>${escapeHtml(job.error)}</b>` : ''}</p></div>
|
||
<div class="flux-job-variants">${thumbs}</div><div class="flux-job-variant-ledger">${ledger}</div>
|
||
<div class="flux-job-actions">
|
||
<button type="button" class="small-btn" data-flux-job-load="${job.id}">Parameter laden</button>
|
||
${canCancel ? `<button type="button" class="small-btn danger" data-flux-job-cancel="${job.id}">Abbrechen</button>` : ''}
|
||
${canRetry ? `<button type="button" class="small-btn" data-flux-job-retry="${job.id}">Wiederholen</button>` : ''}
|
||
${canArchive ? `<button type="button" class="small-btn" data-flux-job-archive="${job.id}">Archivieren</button>` : ''}
|
||
${state.fluxJobArchiveMode ? `<button type="button" class="small-btn" data-flux-job-restore="${job.id}">Wiederherstellen</button><button type="button" class="small-btn danger" data-flux-job-delete="${job.id}">Endgültig löschen</button>` : ''}
|
||
</div>
|
||
</div>
|
||
</article>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-flux-job-select]').forEach(input => input.addEventListener('change', () => {
|
||
if (input.checked) state.fluxJobSelected.add(input.dataset.fluxJobSelect); else state.fluxJobSelected.delete(input.dataset.fluxJobSelect);
|
||
input.closest('.flux-job-card')?.classList.toggle('is-selected', input.checked);
|
||
updateFluxJobToolbar();
|
||
}));
|
||
container.querySelectorAll('[data-flux-job-toggle]').forEach(button => button.addEventListener('click', () => {
|
||
const jobId = button.dataset.fluxJobToggle;
|
||
state.fluxJobManuallyToggled.add(jobId);
|
||
if (state.fluxJobExpanded.has(jobId)) state.fluxJobExpanded.delete(jobId); else state.fluxJobExpanded.add(jobId);
|
||
renderFluxJobList();
|
||
}));
|
||
container.querySelectorAll('[data-flux-job-load]').forEach(button => button.addEventListener('click', () => {
|
||
const job = state.fluxJobs.find(entry => entry.id === button.dataset.fluxJobLoad);
|
||
if (job) { applyFluxJobToForm(job); setFluxStudioStatus('Prompt und Parameter wurden aus dem Verlauf geladen.', 'success'); }
|
||
}));
|
||
container.querySelectorAll('[data-flux-job-cancel]').forEach(button => button.addEventListener('click', async () => {
|
||
try { await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobCancel}/cancel`, {}); toast('Abbruch wurde angefordert.'); await loadFluxJobs(); } catch (error) { toast(`Abbruch fehlgeschlagen: ${error.message}`); }
|
||
}));
|
||
container.querySelectorAll('[data-flux-job-retry]').forEach(button => button.addEventListener('click', async () => {
|
||
try { const job = await api('POST', `/api/flux-prompt/jobs/${button.dataset.fluxJobRetry}/retry`, {}); state.fluxJobExpanded.add(job.id); toast('Auftrag wurde erneut eingereiht.'); await loadFluxJobs(); watchFluxJob(job.id); } catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); }
|
||
}));
|
||
container.querySelectorAll('[data-flux-job-archive]').forEach(button => button.addEventListener('click', () => archiveFluxJobs([button.dataset.fluxJobArchive])));
|
||
container.querySelectorAll('[data-flux-job-restore]').forEach(button => button.addEventListener('click', () => restoreFluxJobs([button.dataset.fluxJobRestore])));
|
||
container.querySelectorAll('[data-flux-job-delete]').forEach(button => button.addEventListener('click', () => deleteArchivedFluxJobs([button.dataset.fluxJobDelete])));
|
||
container.querySelectorAll('[data-flux-job-image]').forEach(button => button.addEventListener('click', () => {
|
||
const [jobId, variantIndex] = button.dataset.fluxJobImage.split(':');
|
||
const job = state.fluxJobs.find(entry => entry.id === jobId);
|
||
const variant = job?.variants?.find(entry => String(entry.variant_index) === variantIndex);
|
||
const item = fluxVariantToImage(job, variant);
|
||
if (item) { renderFluxStudioPreview(item); renderFluxVariantResults(job); }
|
||
}));
|
||
updateFluxJobToolbar();
|
||
}
|
||
|
||
async function loadFluxJobs({ silent = false } = {}) {
|
||
const container = document.getElementById('flux-job-list');
|
||
if (!silent && container && !state.fluxJobs.length) container.innerHTML = '<div class="empty-state">Job-Center wird geladen …</div>';
|
||
try {
|
||
const filter = state.fluxJobArchiveMode ? '' : (document.getElementById('flux-job-filter')?.value || '');
|
||
const params = new URLSearchParams({ page: String(state.fluxJobPage), page_size: String(state.fluxJobPageSize), archived: state.fluxJobArchiveMode ? '1' : '0' });
|
||
if (filter) params.set('status', filter);
|
||
const result = await api('GET', `/api/flux-prompt/jobs?${params}`);
|
||
state.fluxJobs = result.jobs || [];
|
||
state.fluxJobSummary = result.summary || null;
|
||
state.fluxJobPagination = result.pagination || { page: 1, total_pages: 1, total: state.fluxJobs.length };
|
||
state.fluxJobPage = Number(state.fluxJobPagination.page || 1);
|
||
state.fluxJobSelected = new Set([...state.fluxJobSelected].filter(id => state.fluxJobs.some(job => job.id === id)));
|
||
for (const job of state.fluxJobs) if (FLUX_ACTIVE_JOB_STATUSES.has(job.status) && !state.fluxJobManuallyToggled.has(job.id)) state.fluxJobExpanded.add(job.id);
|
||
renderFluxJobSummary(); renderFluxJobList(); renderFluxJobPagination(); updateFluxJobToolbar();
|
||
if (!state.fluxJobArchiveMode) for (const job of state.fluxJobs.filter(entry => FLUX_ACTIVE_JOB_STATUSES.has(entry.status))) watchFluxJob(job.id);
|
||
} catch (error) {
|
||
if (!silent && container) container.innerHTML = `<div class="empty-state">Job-Center konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function bytesToGiB(value) {
|
||
const number = Number(value || 0);
|
||
return number > 0 ? `${(number / 1024 / 1024 / 1024).toFixed(1)} GB` : '–';
|
||
}
|
||
|
||
function renderFluxRuntimeInfo() {
|
||
const container = document.getElementById('flux-runtime-summary');
|
||
if (!container) return;
|
||
const runtime = state.fluxRuntime;
|
||
if (!runtime) { container.innerHTML = '<div class="empty-state">Keine Systemdaten verfügbar.</div>'; return; }
|
||
const stats = runtime.system_stats || {};
|
||
const system = stats.system || stats;
|
||
const devices = Array.isArray(stats.devices) ? stats.devices : Array.isArray(system.devices) ? system.devices : [];
|
||
const gpu = devices[0] || {};
|
||
const queue = runtime.queue_summary || {};
|
||
const historyCount = runtime.history ? Object.keys(runtime.history).length : 0;
|
||
container.innerHTML = `
|
||
<div class="flux-runtime-grid">
|
||
<div><span>ComfyUI</span><strong>${runtime.reachable ? 'Erreichbar' : 'Nicht erreichbar'}</strong><small>${escapeHtml(runtime.url || '')}</small></div>
|
||
<div><span>ComfyUI-Queue</span><strong>${queue.running || 0} aktiv · ${queue.pending || 0} wartend</strong><small>${historyCount} Historieneinträge geladen</small></div>
|
||
<div><span>GPU</span><strong>${escapeHtml(gpu.name || gpu.type || 'Nicht gemeldet')}</strong><small>${gpu.vram_total ? `${bytesToGiB(gpu.vram_free)} frei von ${bytesToGiB(gpu.vram_total)}` : 'VRAM-Daten nicht gemeldet'}</small></div>
|
||
<div><span>System</span><strong>${escapeHtml(system.os || system.system || system.python_version || 'ComfyUI-System')}</strong><small>${escapeHtml(system.comfyui_version || system.version || '')}</small></div>
|
||
</div>`;
|
||
}
|
||
|
||
async function refreshFluxRuntimeInfo({ silent = false } = {}) {
|
||
const container = document.getElementById('flux-runtime-summary');
|
||
if (!silent && container) container.innerHTML = '<div class="empty-state">ComfyUI-Warteschlange, Historie und GPU-Daten werden geladen …</div>';
|
||
try {
|
||
state.fluxRuntime = await api('GET', '/api/flux-prompt/runtime');
|
||
renderFluxRuntimeInfo();
|
||
} catch (error) {
|
||
state.fluxRuntime = null;
|
||
if (container) container.innerHTML = `<div class="empty-state">Systemdaten nicht verfügbar: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function useFluxImageInGenerator() {
|
||
const item = state.fluxStudioCurrent;
|
||
if (!item?.id) return;
|
||
try {
|
||
const result = await api('POST', `/api/flux-studio/images/${item.id}/copy-to-generator`, {});
|
||
if (result.filename && !state.photos.includes(result.filename)) state.photos.push(result.filename);
|
||
renderPhotoPreview();
|
||
switchToTab('generator');
|
||
toast('Eine unabhängige Kopie wurde als Artikelfoto übernommen.');
|
||
} catch (error) { toast(`Übernahme fehlgeschlagen: ${error.message}`); }
|
||
}
|
||
|
||
|
||
function aiProviderDisplayName(provider = state.aiProvider) {
|
||
return state.providers.find(entry => entry.id === provider)?.name || ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || 'AI';
|
||
}
|
||
|
||
function openFluxPromptLab() {
|
||
const prompt = document.getElementById('flux-studio-prompt');
|
||
if (!prompt?.value.trim()) return toast('Bitte zuerst einen Prompt eingeben.');
|
||
const modal = document.getElementById('flux-prompt-lab');
|
||
document.getElementById('flux-prompt-lab-original').value = prompt.value.trim();
|
||
document.getElementById('flux-prompt-lab-result').value = '';
|
||
document.getElementById('flux-prompt-lab-notes').textContent = '';
|
||
document.getElementById('flux-prompt-lab-status').textContent = 'Bereit für lokale Strukturierung oder AI-Veredelung.';
|
||
document.getElementById('flux-prompt-lab-provider').textContent = `${aiProviderDisplayName()}${state.aiModel ? ` · ${state.aiModel}` : ' · Auto'}`;
|
||
modal?.classList.remove('hidden');
|
||
modal?.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
setTimeout(() => document.getElementById('flux-prompt-lab-original')?.focus(), 40);
|
||
}
|
||
|
||
function closeFluxPromptLab() {
|
||
const modal = document.getElementById('flux-prompt-lab');
|
||
modal?.classList.add('hidden');
|
||
modal?.setAttribute('aria-hidden', 'true');
|
||
if (document.getElementById('smart-copy-modal')?.classList.contains('hidden')) document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
function setupFluxPromptLab() {
|
||
document.querySelectorAll('[data-flux-prompt-lab-close]').forEach(button => button.addEventListener('click', closeFluxPromptLab));
|
||
document.getElementById('flux-prompt-lab-local')?.addEventListener('click', () => {
|
||
const original = document.getElementById('flux-prompt-lab-original')?.value || '';
|
||
const optimized = optimizeFluxPromptLocally(original);
|
||
if (!optimized) return toast('Bitte zuerst einen Prompt eingeben.');
|
||
document.getElementById('flux-prompt-lab-result').value = optimized;
|
||
document.getElementById('flux-prompt-lab-status').textContent = 'Lokal strukturiert · keine API-Anfrage.';
|
||
document.getElementById('flux-prompt-lab-notes').textContent = 'Motiv, Licht, Komposition und Qualitätsmerkmale wurden in eine klarere Reihenfolge gebracht.';
|
||
});
|
||
document.getElementById('flux-prompt-lab-ai')?.addEventListener('click', async event => {
|
||
const original = document.getElementById('flux-prompt-lab-original')?.value.trim() || '';
|
||
if (!original) return toast('Bitte zuerst einen Prompt eingeben.');
|
||
const button = event.currentTarget;
|
||
const status = document.getElementById('flux-prompt-lab-status');
|
||
button.disabled = true;
|
||
button.textContent = `${aiProviderDisplayName()} arbeitet …`;
|
||
status.textContent = `Prompt wird mit ${aiProviderDisplayName()} veredelt …`;
|
||
try {
|
||
const payload = {
|
||
prompt: original,
|
||
provider: state.aiProvider,
|
||
model: state.aiModel || undefined,
|
||
goal: document.getElementById('flux-prompt-lab-goal')?.value || 'product',
|
||
preserve_subject: document.getElementById('flux-prompt-lab-preserve')?.checked !== false,
|
||
avoid_text: document.getElementById('flux-prompt-lab-avoid-text')?.checked !== false,
|
||
};
|
||
const result = await api('POST', '/api/flux-prompt/improve', payload);
|
||
document.getElementById('flux-prompt-lab-result').value = result.improved_prompt || '';
|
||
document.getElementById('flux-prompt-lab-notes').textContent = result.notes || 'Prompt wurde für FLUX veredelt.';
|
||
status.textContent = `Fertig · ${aiProviderDisplayName(result.provider)}${result.model ? ` · ${result.model}` : ''}`;
|
||
} catch (error) {
|
||
status.textContent = `AI-Veredelung fehlgeschlagen: ${error.message}`;
|
||
toast(`Prompt Lab: ${error.message}`);
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = 'Mit AI veredeln';
|
||
}
|
||
});
|
||
document.getElementById('flux-prompt-lab-apply')?.addEventListener('click', () => {
|
||
const result = document.getElementById('flux-prompt-lab-result')?.value.trim();
|
||
if (!result) return toast('Erzeuge zuerst einen optimierten Prompt.');
|
||
const prompt = document.getElementById('flux-studio-prompt');
|
||
prompt.value = result.slice(0, 2000);
|
||
prompt.dispatchEvent(new Event('input', { bubbles: true }));
|
||
prompt.focus();
|
||
closeFluxPromptLab();
|
||
toast('Optimierter Prompt wurde übernommen.');
|
||
});
|
||
}
|
||
|
||
function setupFluxStudio() {
|
||
const prompt = document.getElementById('flux-studio-prompt');
|
||
if (!prompt) return;
|
||
const updateCount = () => { const count = document.getElementById('flux-studio-prompt-count'); if (count) count.textContent = String(prompt.value.length); };
|
||
prompt.addEventListener('input', () => { updateCount(); refreshFluxPromptQuality(); });
|
||
updateCount();
|
||
refreshFluxPromptQuality();
|
||
document.getElementById('flux-studio-optimize-prompt')?.addEventListener('click', openFluxPromptLab);
|
||
setupFluxPromptLab();
|
||
document.querySelectorAll('[data-flux-profile]').forEach(button => button.addEventListener('click', () => applyFluxProfile(button.dataset.fluxProfile)));
|
||
document.querySelectorAll('[data-flux-variants]').forEach(button => button.addEventListener('click', () => setFluxVariantCount(button.dataset.fluxVariants)));
|
||
document.getElementById('flux-studio-format')?.addEventListener('change', () => applyFluxProfile('custom'));
|
||
document.getElementById('flux-studio-steps')?.addEventListener('change', () => applyFluxProfile('custom'));
|
||
let savedFluxProfile = 'balanced'; try { savedFluxProfile = localStorage.getItem('vendoo-flux-profile') || savedFluxProfile; } catch {}
|
||
let savedFluxVariants = 1; try { savedFluxVariants = Number(localStorage.getItem('vendoo-flux-variants') || 1); } catch {}
|
||
applyFluxProfile(savedFluxProfile, { persist: false });
|
||
setFluxVariantCount(savedFluxVariants, { persist: false });
|
||
renderFluxStudioPreview(null);
|
||
const initialVariantResults = document.getElementById('flux-studio-variant-results');
|
||
if (initialVariantResults) { initialVariantResults.innerHTML = ''; initialVariantResults.classList.add('hidden'); }
|
||
document.querySelectorAll('[data-flux-template]').forEach(button => button.addEventListener('click', () => {
|
||
const template = FLUX_PROMPT_TEMPLATES[button.dataset.fluxTemplate];
|
||
if (!template) return;
|
||
prompt.value = prompt.value.trim() ? `${prompt.value.trim()}, ${template}` : template;
|
||
updateCount(); refreshFluxPromptQuality(); prompt.focus();
|
||
}));
|
||
document.getElementById('flux-studio-random-seed')?.addEventListener('click', () => {
|
||
document.getElementById('flux-studio-seed').value = String(Math.floor(Math.random() * 2147483647));
|
||
});
|
||
document.getElementById('flux-studio-generate')?.addEventListener('click', () => runFluxStudioGeneration());
|
||
document.getElementById('flux-studio-reset')?.addEventListener('click', () => {
|
||
if (state.fluxStudioBusy && !window.confirm('Ein FLUX-Auftrag läuft noch. Nur den sichtbaren Arbeitsbereich zurücksetzen? Der Auftrag läuft im Job-Center weiter.')) return;
|
||
resetFluxStudioWorkspace({ clearSavedDefaults: true, announce: true });
|
||
});
|
||
document.getElementById('flux-studio-refresh-engine')?.addEventListener('click', async () => { await refreshFluxStudioEngineStatus(); await refreshFluxRuntimeInfo(); });
|
||
document.getElementById('flux-runtime-refresh')?.addEventListener('click', () => refreshFluxRuntimeInfo());
|
||
document.getElementById('flux-job-refresh')?.addEventListener('click', () => loadFluxJobs());
|
||
document.getElementById('flux-job-filter')?.addEventListener('change', () => { state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); });
|
||
document.getElementById('flux-job-page-size')?.addEventListener('change', event => { state.fluxJobPageSize = Number(event.target.value) || 10; state.fluxJobPage = 1; loadFluxJobs(); });
|
||
document.getElementById('flux-job-view-active')?.addEventListener('click', () => { state.fluxJobArchiveMode = false; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); });
|
||
document.getElementById('flux-job-view-archive')?.addEventListener('click', () => { state.fluxJobArchiveMode = true; state.fluxJobPage = 1; state.fluxJobSelected.clear(); loadFluxJobs(); });
|
||
document.getElementById('flux-job-prev')?.addEventListener('click', () => { if (state.fluxJobPage > 1) { state.fluxJobPage -= 1; loadFluxJobs(); } });
|
||
document.getElementById('flux-job-next')?.addEventListener('click', () => { if (state.fluxJobPage < Number(state.fluxJobPagination.total_pages || 1)) { state.fluxJobPage += 1; loadFluxJobs(); } });
|
||
document.getElementById('flux-job-select-page')?.addEventListener('change', event => { for (const job of state.fluxJobs) { if (event.target.checked) state.fluxJobSelected.add(job.id); else state.fluxJobSelected.delete(job.id); } renderFluxJobList(); });
|
||
document.getElementById('flux-job-archive-selected')?.addEventListener('click', () => archiveFluxJobs([...state.fluxJobSelected]));
|
||
document.getElementById('flux-job-restore-selected')?.addEventListener('click', () => restoreFluxJobs([...state.fluxJobSelected]));
|
||
document.getElementById('flux-job-delete-selected')?.addEventListener('click', () => deleteArchivedFluxJobs([...state.fluxJobSelected]));
|
||
document.getElementById('flux-job-delete-all-archive')?.addEventListener('click', () => deleteArchivedFluxJobs([], true));
|
||
document.getElementById('flux-job-collapse-all')?.addEventListener('click', () => {
|
||
for (const job of state.fluxJobs) state.fluxJobManuallyToggled.add(job.id);
|
||
state.fluxJobExpanded.clear();
|
||
renderFluxJobList();
|
||
});
|
||
const jobCenterToggle = document.getElementById('flux-job-center-toggle');
|
||
const jobCenterBody = document.getElementById('flux-job-center-body');
|
||
const applyJobCenterState = () => {
|
||
jobCenterBody?.classList.toggle('hidden', state.fluxJobCenterCollapsed);
|
||
if (jobCenterToggle) {
|
||
jobCenterToggle.setAttribute('aria-expanded', String(!state.fluxJobCenterCollapsed));
|
||
jobCenterToggle.textContent = state.fluxJobCenterCollapsed ? 'Aufträge aufklappen' : 'Aufträge einklappen';
|
||
}
|
||
};
|
||
try { state.fluxJobCenterCollapsed = localStorage.getItem('vendoo-flux-job-center-collapsed') === '1'; } catch {}
|
||
applyJobCenterState();
|
||
jobCenterToggle?.addEventListener('click', () => {
|
||
state.fluxJobCenterCollapsed = !state.fluxJobCenterCollapsed;
|
||
try { localStorage.setItem('vendoo-flux-job-center-collapsed', state.fluxJobCenterCollapsed ? '1' : '0'); } catch {}
|
||
applyJobCenterState();
|
||
});
|
||
document.getElementById('flux-job-clear-queue')?.addEventListener('click', async () => {
|
||
if (!window.confirm('Alle noch wartenden FLUX-Varianten aus der Warteschlange entfernen? Laufende Varianten bleiben unberührt.')) return;
|
||
try { const result = await api('DELETE', '/api/flux-prompt/jobs/queue'); toast(`${result.cleared_variants || 0} wartende Variante(n) entfernt.`); await loadFluxJobs(); }
|
||
catch (error) { toast(`Warteschlange konnte nicht geleert werden: ${error.message}`); }
|
||
});
|
||
document.getElementById('flux-studio-refresh-history')?.addEventListener('click', () => loadFluxStudioHistory());
|
||
document.getElementById('flux-history-page-size')?.addEventListener('change', event => { state.fluxHistoryPageSize = Number(event.target.value) || 5; state.fluxHistoryPage = 1; loadFluxStudioHistory(); });
|
||
document.getElementById('flux-history-prev')?.addEventListener('click', () => { if (state.fluxHistoryPage > 1) { state.fluxHistoryPage -= 1; loadFluxStudioHistory(); } });
|
||
document.getElementById('flux-history-next')?.addEventListener('click', () => { if (state.fluxHistoryPage < Number(state.fluxHistoryPagination.total_pages || 1)) { state.fluxHistoryPage += 1; loadFluxStudioHistory(); } });
|
||
document.getElementById('flux-studio-favorites-only')?.addEventListener('change', () => { state.fluxHistoryPage = 1; loadFluxStudioHistory(); });
|
||
document.getElementById('flux-studio-edit')?.addEventListener('click', () => openPhotoEditor(0, 'flux'));
|
||
document.getElementById('flux-studio-similar')?.addEventListener('click', () => createSimilarFluxJob());
|
||
document.getElementById('flux-studio-favorite')?.addEventListener('click', () => toggleFluxFavorite());
|
||
document.getElementById('flux-studio-use-in-generator')?.addEventListener('click', useFluxImageInGenerator);
|
||
document.getElementById('flux-studio-delete')?.addEventListener('click', () => deleteFluxStudioImage());
|
||
document.getElementById('open-flux-studio-page-btn')?.addEventListener('click', () => switchToTab('flux-studio'));
|
||
document.getElementById('flux-studio-start-engine')?.addEventListener('click', async () => {
|
||
const button = document.getElementById('flux-studio-start-engine');
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const profile = document.getElementById('setting-flux-runtime-profile')?.value || 'auto';
|
||
const result = await api('POST', '/api/local-image/start', { profile });
|
||
toast(result.message || 'FLUX wird gestartet.');
|
||
await waitForFluxEngineReady();
|
||
setFluxStudioStatus('FLUX ist aktiv und bereit.', 'success');
|
||
await refreshFluxRuntimeInfo();
|
||
toast('FLUX ist aktiv und bereit.');
|
||
} catch (error) {
|
||
setFluxStudioStatus(`FLUX-Start fehlgeschlagen: ${error.message}`, 'error');
|
||
toast(`FLUX-Start fehlgeschlagen: ${error.message}`);
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
});
|
||
document.getElementById('flux-studio-stop-engine')?.addEventListener('click', async () => {
|
||
try { const result = await api('POST', '/api/local-image/stop', {}); toast(result.message || 'FLUX wird gestoppt.'); setTimeout(refreshFluxStudioEngineStatus, 1200); }
|
||
catch (error) { toast(`FLUX-Stopp fehlgeschlagen: ${error.message}`); }
|
||
});
|
||
refreshFluxStudioEngineStatus();
|
||
loadFluxStudioHistory();
|
||
loadFluxJobs();
|
||
refreshFluxRuntimeInfo();
|
||
const poller = window.setInterval(() => {
|
||
const page = document.getElementById('flux-studio');
|
||
if (page?.classList.contains('active')) {
|
||
loadFluxJobs({ silent: true });
|
||
refreshFluxRuntimeInfo({ silent: true });
|
||
}
|
||
}, 5000);
|
||
window.addEventListener('beforeunload', () => window.clearInterval(poller), { once: true });
|
||
}
|
||
|
||
|
||
function updateAiModeControls(scope = 'generator') {
|
||
const promptButton = document.getElementById(`${scope}-ai-model-prompt-btn`);
|
||
if (promptButton) {
|
||
promptButton.disabled = false;
|
||
promptButton.title = 'Optionale Zusatzanweisung';
|
||
}
|
||
}
|
||
|
||
async function runGeneratorAiModelPhotos() {
|
||
if (!state.photos.length) return toast('Bitte zuerst Produktfotos hochladen');
|
||
const button = document.getElementById('generator-ai-model-btn');
|
||
button.disabled = true;
|
||
setAiModelStatus('generator-ai-model-status', 'Sicherheitsprüfung läuft …', 'working');
|
||
try {
|
||
const draft = getAiPromptDraft('generator');
|
||
const payload = { ...getGeneratorAiModelPayload(), custom_prompt: draft.customPrompt || '', preserve_logos: draft.preserveLogos ? '1' : '0' };
|
||
const precheck = await api('POST', '/api/ai-model-photos/precheck', payload);
|
||
if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert');
|
||
setAiModelStatus('generator-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working');
|
||
const queued = await api('POST', '/api/ai-model-photos/jobs', payload);
|
||
const finalJob = await pollAiModelJob(queued.job_id, (job) => {
|
||
setAiModelJobProgress('generator-ai-model-status', job);
|
||
});
|
||
if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.');
|
||
state.aiModelPhotos = finalJob.photos || [];
|
||
renderAiModelGrid('generator', state.aiModelPhotos);
|
||
const providerLabel = state.aiModelPhotos[0]?.provider_model || state.aiModelPhotos[0]?.provider || 'AI';
|
||
setAiModelStatus('generator-ai-model-status', `${state.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success');
|
||
} catch (err) {
|
||
setAiModelStatus('generator-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger');
|
||
toast(err.message || 'AI-Model-Fotos fehlgeschlagen');
|
||
} finally {
|
||
button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function runDetailAiModelPhotos() {
|
||
if (!detailEditorState?.photos?.length) return toast('Bitte zuerst Produktfotos hinzufügen');
|
||
const button = document.getElementById('detail-ai-model-btn');
|
||
if (button) button.disabled = true;
|
||
setAiModelStatus('detail-ai-model-status', 'Sicherheitsprüfung läuft …', 'working');
|
||
try {
|
||
const draft = getAiPromptDraft('detail');
|
||
const payload = {
|
||
listing_id: detailEditorState.listing.id,
|
||
photos: [...detailEditorState.photos],
|
||
platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform,
|
||
title: document.getElementById('detail-title')?.value || detailEditorState.listing.title || '',
|
||
category: detailEditorState.categoryPicker?.getValue?.() || detailEditorState.listing.category || '',
|
||
brand: document.getElementById('detail-brand')?.value || detailEditorState.listing.brand || '',
|
||
color: document.getElementById('detail-color')?.value || detailEditorState.listing.color || '',
|
||
size: document.getElementById('detail-size')?.value || detailEditorState.listing.size || '',
|
||
condition: document.getElementById('detail-condition')?.value || detailEditorState.listing.condition || '',
|
||
seller_notes: document.getElementById('detail-seller-notes')?.value || detailEditorState.listing.seller_notes || '',
|
||
mode: document.getElementById('detail-ai-model-mode')?.value || 'flatlay',
|
||
preset: document.getElementById('detail-ai-model-preset')?.value || 'mixed',
|
||
variants: parseInt(document.getElementById('detail-ai-model-variants')?.value || '1', 10),
|
||
custom_prompt: draft.customPrompt || '',
|
||
preserve_logos: draft.preserveLogos ? '1' : '0',
|
||
};
|
||
const precheck = await api('POST', '/api/ai-model-photos/precheck', payload);
|
||
if (!precheck.allowed) throw new Error(precheck.reason || 'AI-Model-Fotos blockiert');
|
||
setAiModelStatus('detail-ai-model-status', `Sicherheitsprüfung bestanden. Job wird eingereiht (${precheck.mode}, ${precheck.variants} Bild/Bilder) …`, 'working');
|
||
const queued = await api('POST', '/api/ai-model-photos/jobs', payload);
|
||
const finalJob = await pollAiModelJob(queued.job_id, (job) => {
|
||
setAiModelJobProgress('detail-ai-model-status', job);
|
||
});
|
||
if (finalJob.status !== 'completed') throw new Error(finalJob.error_message || 'AI-Model-Fotos konnten nicht erzeugt werden.');
|
||
detailEditorState.aiModelPhotos = finalJob.photos || [];
|
||
renderAiModelGrid('detail', detailEditorState.aiModelPhotos);
|
||
const providerLabel = detailEditorState.aiModelPhotos[0]?.provider_model || detailEditorState.aiModelPhotos[0]?.provider || 'AI';
|
||
setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} sichere AI-Bildvariante(n) erzeugt · ${providerLabel}`, 'success');
|
||
loadDetailAiModelGenerations(detailEditorState.listing.id);
|
||
} catch (err) {
|
||
setAiModelStatus('detail-ai-model-status', err.message || 'AI-Model-Fotos konnten nicht erzeugt werden.', 'danger');
|
||
toast(err.message || 'AI-Model-Fotos fehlgeschlagen');
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function loadDetailAiModelPhotos(listingId) {
|
||
try {
|
||
const items = await api('GET', `/api/listings/${listingId}/ai-model-photos`);
|
||
detailEditorState.aiModelPhotos = items || [];
|
||
renderAiModelGrid('detail', detailEditorState.aiModelPhotos);
|
||
if (detailEditorState.aiModelPhotos.length) {
|
||
setAiModelStatus('detail-ai-model-status', `${detailEditorState.aiModelPhotos.length} vorhandene AI-Bilder geladen.`, 'neutral');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
function renderDetailAiModelGenerations(generations = []) {
|
||
const wrap = document.getElementById('detail-ai-model-history');
|
||
if (!wrap) return;
|
||
if (!generations.length) {
|
||
wrap.innerHTML = '<div class="ai-generation-empty">Noch keine AI-Bildhistorie vorhanden.</div>';
|
||
return;
|
||
}
|
||
wrap.innerHTML = generations.slice(0, 6).map(gen => `
|
||
<article class="ai-generation-card">
|
||
<header><strong>${escapeHtml((gen.mode || 'model').replace('_', ' '))}</strong><span>${escapeHtml((gen.preset || 'mixed').toUpperCase())} · ${gen.variants || 0} Bild/Bilder</span></header>
|
||
<div class="ai-generation-mini-grid">
|
||
${(gen.photos || []).slice(0, 3).map(photo => `<button type="button" class="ai-generation-thumb" data-ai-model-add-one="${escapeHtml(photo.image_path)}" data-ai-model-scope="detail"><img src="${escapeHtml(photoUrl(photo.image_path))}" alt="AI-Historie"></button>`).join('')}
|
||
</div>
|
||
</article>
|
||
`).join('');
|
||
wrap.querySelectorAll('[data-ai-model-add-one]').forEach(button => button.addEventListener('click', () => {
|
||
const file = button.dataset.aiModelAddOne;
|
||
if (!detailEditorState.photos.includes(file)) detailEditorState.photos.push(file);
|
||
renderDetailPhotos();
|
||
toast('AI-Bild aus Historie hinzugefügt');
|
||
}));
|
||
}
|
||
|
||
async function loadDetailAiModelGenerations(listingId) {
|
||
try {
|
||
const items = await api('GET', `/api/listings/${listingId}/ai-model-generations`);
|
||
detailEditorState.aiModelGenerations = items || [];
|
||
renderDetailAiModelGenerations(detailEditorState.aiModelGenerations);
|
||
} catch {}
|
||
}
|
||
|
||
|
||
|
||
// --- Tab titles for topbar ---
|
||
const TAB_TITLES = {
|
||
today: 'Studio Flow',
|
||
dashboard: 'Dashboard',
|
||
generator: 'Neuer Artikel',
|
||
templates: 'Vorlagen',
|
||
history: 'Artikel',
|
||
'quality-center': 'Qualitätscenter',
|
||
publish: 'Publish',
|
||
trash: 'Papierkorb',
|
||
inventory: 'Lager',
|
||
fees: 'Gebühren',
|
||
admin: 'Admin / Benutzer & System',
|
||
'flux-studio': 'FLUX Studio',
|
||
'media-library': 'Bildergalerie',
|
||
'image-factory': 'Bildproduktion',
|
||
extensions: 'Extensions',
|
||
settings: 'Einstellungen',
|
||
};
|
||
|
||
const TAB_KICKERS = {
|
||
today: 'Arbeitsplatz',
|
||
dashboard: 'Analytics',
|
||
generator: 'Generator',
|
||
templates: 'Inhalte',
|
||
history: 'Übersicht',
|
||
'quality-center': 'Artikelprüfung',
|
||
publish: 'Control Center',
|
||
trash: 'Wiederherstellung',
|
||
inventory: 'Bestand',
|
||
fees: 'Kalkulation',
|
||
admin: 'Verwaltung',
|
||
'flux-studio': 'Bildgenerator',
|
||
'media-library': 'Medienverwaltung',
|
||
'image-factory': 'Batch Image Factory',
|
||
extensions: 'Browser-Integration',
|
||
settings: 'Konfiguration',
|
||
};
|
||
|
||
function setMobileSidebarOpen(open) {
|
||
const sidebar = document.getElementById('sidebar');
|
||
const overlay = document.getElementById('sidebar-overlay');
|
||
const toggle = document.getElementById('sidebar-toggle');
|
||
if (!sidebar || !overlay) return;
|
||
sidebar.classList.toggle('open', !!open);
|
||
overlay.classList.toggle('show', !!open);
|
||
document.body.classList.toggle('mobile-nav-open', !!open);
|
||
toggle?.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||
sidebar.setAttribute('aria-hidden', (!open && window.matchMedia('(max-width: 768px)').matches) ? 'true' : 'false');
|
||
if (open) {
|
||
window.setTimeout(() => sidebar.querySelector('.nav-item.active, .nav-item')?.focus(), 60);
|
||
} else {
|
||
toggle?.focus({ preventScroll: true });
|
||
}
|
||
}
|
||
|
||
function closeMobileSidebar({ restoreFocus = false } = {}) {
|
||
const toggle = document.getElementById('sidebar-toggle');
|
||
const sidebar = document.getElementById('sidebar');
|
||
const overlay = document.getElementById('sidebar-overlay');
|
||
sidebar?.classList.remove('open');
|
||
overlay?.classList.remove('show');
|
||
document.body.classList.remove('mobile-nav-open');
|
||
toggle?.setAttribute('aria-expanded', 'false');
|
||
if (sidebar && window.matchMedia('(max-width: 768px)').matches) sidebar.setAttribute('aria-hidden', 'true');
|
||
if (restoreFocus) toggle?.focus({ preventScroll: true });
|
||
}
|
||
|
||
function switchToTab(tabId, adminTab) {
|
||
const requestedNav = document.querySelector(`.nav-item[data-tab="${tabId}"]`);
|
||
if (requestedNav?.classList.contains('hidden')) {
|
||
toast('Dieses Modul ist derzeit deaktiviert.');
|
||
tabId = 'dashboard';
|
||
adminTab = null;
|
||
}
|
||
if (tabId !== 'history' && listingEditLock.listingId) releaseListingLock({ quiet: true });
|
||
document.querySelectorAll('.nav-item').forEach(b => b.classList.remove('active'));
|
||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||
const section = document.getElementById(tabId);
|
||
if (section) section.classList.add('active');
|
||
window.requestAnimationFrame(applyResponsiveContract);
|
||
|
||
// Highlight the correct sidebar item
|
||
const selector = adminTab
|
||
? `.nav-item[data-tab="${tabId}"][data-admin-tab="${adminTab}"]`
|
||
: `.nav-item[data-tab="${tabId}"]:not([data-admin-tab])`;
|
||
const activeBtn = document.querySelector(selector)
|
||
|| document.querySelector(`.nav-item[data-tab="${tabId}"]`);
|
||
if (activeBtn) activeBtn.classList.add('active');
|
||
|
||
// Update topbar title
|
||
const titleEl = document.getElementById('topbar-title');
|
||
if (titleEl) titleEl.textContent = TAB_TITLES[tabId] || tabId;
|
||
const kickerEl = document.getElementById('topbar-kicker');
|
||
if (kickerEl) kickerEl.textContent = TAB_KICKERS[tabId] || '';
|
||
|
||
// Switch admin sub-tab if specified
|
||
if (tabId === 'admin' && adminTab) {
|
||
switchAdminTab(adminTab);
|
||
if (adminTab === 'users') loadAdminUsers();
|
||
if (adminTab === 'roles') loadRoleMatrix();
|
||
if (adminTab === 'sessions') loadAdminSessions();
|
||
if (adminTab === 'smtp') loadSmtpSettings();
|
||
if (adminTab === 'system') { loadSystemInfo(); loadOperationsCenter(); }
|
||
if (adminTab === 'modules') globalThis.VendooModuleManager?.load?.();
|
||
if (adminTab === 'security') loadSecurityCenter();
|
||
if (adminTab === 'audit') loadAdminAudit();
|
||
}
|
||
|
||
// Load data for the tab
|
||
if (tabId === 'today') loadToday();
|
||
if (tabId === 'dashboard') loadAnalyticsDashboard();
|
||
if (tabId === 'history') { setHistoryDetailMode(false); loadHistory(); }
|
||
if (tabId === 'quality-center') loadQualityCenter();
|
||
if (tabId === 'publish') loadPublishList();
|
||
if (tabId === 'trash') loadTrash();
|
||
if (tabId === 'templates') renderTemplateList();
|
||
if (tabId === 'inventory') loadInventory();
|
||
if (tabId === 'fees') updateFeeCalculator();
|
||
if (tabId === 'flux-studio') { refreshFluxStudioEngineStatus(); loadFluxStudioHistory(); loadFluxJobs({ silent: true }); }
|
||
if (tabId === 'media-library') loadMediaLibrary();
|
||
if (tabId === 'image-factory') loadImageFactory();
|
||
if (tabId === 'extensions') loadExtensions();
|
||
|
||
// Close mobile sidebar
|
||
closeMobileSidebar();
|
||
}
|
||
|
||
// --- Tabs ---
|
||
function setupTabs() {
|
||
document.querySelectorAll('.nav-item').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
switchToTab(btn.dataset.tab, btn.dataset.adminTab);
|
||
});
|
||
});
|
||
|
||
// Mobile sidebar toggle
|
||
document.getElementById('sidebar-toggle')?.addEventListener('click', event => {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
const open = !document.getElementById('sidebar')?.classList.contains('open');
|
||
setMobileSidebarOpen(open);
|
||
});
|
||
document.getElementById('sidebar-close')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true }));
|
||
document.getElementById('sidebar-overlay')?.addEventListener('click', () => closeMobileSidebar({ restoreFocus: true }));
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && document.getElementById('sidebar')?.classList.contains('open')) {
|
||
closeMobileSidebar({ restoreFocus: true });
|
||
}
|
||
});
|
||
window.addEventListener('resize', debounce(() => {
|
||
const mobile = window.matchMedia('(max-width: 768px)').matches;
|
||
const sidebar = document.getElementById('sidebar');
|
||
if (!mobile) {
|
||
closeMobileSidebar();
|
||
sidebar?.setAttribute('aria-hidden', 'false');
|
||
} else if (!sidebar?.classList.contains('open')) {
|
||
sidebar?.setAttribute('aria-hidden', 'true');
|
||
}
|
||
}, 120));
|
||
if (window.matchMedia('(max-width: 768px)').matches) document.getElementById('sidebar')?.setAttribute('aria-hidden', 'true');
|
||
|
||
// Topbar "Neuer Artikel" button
|
||
document.getElementById('topbar-new-btn')?.addEventListener('click', () => {
|
||
switchToTab('generator');
|
||
});
|
||
|
||
// Topbar search button — focus search in history
|
||
document.getElementById('topbar-search-btn')?.addEventListener('click', () => {
|
||
switchToTab('history');
|
||
setTimeout(() => document.getElementById('search-input')?.focus(), 100);
|
||
});
|
||
|
||
document.addEventListener('keydown', event => {
|
||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||
event.preventDefault();
|
||
switchToTab('history');
|
||
setTimeout(() => document.getElementById('search-input')?.focus(), 100);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', event => {
|
||
const target = event.target.closest('[data-tab-target]');
|
||
if (target) switchToTab(target.dataset.tabTarget);
|
||
});
|
||
}
|
||
|
||
// --- Platforms ---
|
||
function renderPlatformButtons() {
|
||
const c = document.getElementById('platform-buttons');
|
||
c.innerHTML = '';
|
||
for (const p of state.platforms) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'toggle-btn' + (p.id === state.platform ? ' active' : '');
|
||
btn.textContent = p.name;
|
||
btn.addEventListener('click', () => {
|
||
c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.platform = p.id;
|
||
updateGeneratorReadiness();
|
||
refreshGeneratorPlatformPreview();
|
||
if (!document.getElementById('meta-section').classList.contains('hidden') && metaCategoryPicker) {
|
||
const currentVal = metaCategoryPicker.getValue();
|
||
metaCategoryPicker = createCategoryPicker('meta-category-picker', p.id, null);
|
||
if (currentVal) {
|
||
const name = currentVal.split(' > ').pop();
|
||
api('GET', `/api/categories/${p.id}/match?q=${encodeURIComponent(name)}`).then(matches => {
|
||
if (matches.length && matches[0].score >= 50) metaCategoryPicker.setValue(matches[0].pathStr);
|
||
}).catch(() => {});
|
||
}
|
||
}
|
||
});
|
||
c.appendChild(btn);
|
||
}
|
||
}
|
||
|
||
// --- Providers ---
|
||
function renderProviderButtons() {
|
||
const c = document.getElementById('provider-buttons');
|
||
c.innerHTML = '';
|
||
for (const p of state.providers) {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'toggle-btn' + (p.id === state.aiProvider ? ' active' : '');
|
||
btn.textContent = p.name;
|
||
btn.addEventListener('click', () => {
|
||
c.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
state.aiProvider = p.id;
|
||
state.aiModel = '';
|
||
updateModelDropdown();
|
||
});
|
||
c.appendChild(btn);
|
||
}
|
||
updateModelDropdown();
|
||
}
|
||
|
||
function updateModelDropdown() {
|
||
const wrap = document.getElementById('model-select-wrap');
|
||
const sel = document.getElementById('model-select');
|
||
const prov = state.providers.find(p => p.id === state.aiProvider);
|
||
if (prov?.hasModels && prov.models?.length) {
|
||
const isOpenRouter = prov.id === 'openrouter';
|
||
const options = isOpenRouter
|
||
? `<option value="">Auto (Gemini zuerst, dann Free)</option>` + prov.models.map(m => `<option value="${m.id}">${m.name}</option>`).join('')
|
||
: prov.models.map(m => `<option value="${m.id}">${m.name}${m.free ? ' *' : ''}</option>`).join('');
|
||
sel.innerHTML = options;
|
||
state.aiModel = isOpenRouter ? '' : prov.models[0].id;
|
||
sel.onchange = () => { state.aiModel = sel.value; };
|
||
wrap.classList.remove('hidden');
|
||
} else {
|
||
state.aiModel = '';
|
||
wrap.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
// --- Upload ---
|
||
function setupUpload() {
|
||
const dz = document.getElementById('dropzone');
|
||
const fi = document.getElementById('file-input');
|
||
dz.addEventListener('click', () => fi.click());
|
||
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('dragover'); });
|
||
dz.addEventListener('dragleave', () => dz.classList.remove('dragover'));
|
||
dz.addEventListener('drop', e => { e.preventDefault(); dz.classList.remove('dragover'); handleFiles(e.dataTransfer.files); });
|
||
fi.addEventListener('change', () => { handleFiles(fi.files); fi.value = ''; });
|
||
enablePhotoDragSort();
|
||
}
|
||
|
||
async function handleFiles(files) {
|
||
const available = Math.max(0, 10 - state.photos.length);
|
||
const imageFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
|
||
if (!available) { toast('Maximal 10 Fotos pro Listing'); return; }
|
||
const accepted = imageFiles.slice(0, available);
|
||
if (imageFiles.length > available) toast(`Es wurden nur ${available} weitere Fotos übernommen`);
|
||
const fd = new FormData();
|
||
fd.append('platform', state.platform || 'allgemein');
|
||
for (const file of accepted) fd.append('photos', file);
|
||
if (!fd.has('photos')) return;
|
||
try {
|
||
const response = await fetch(`/api/upload?platform=${encodeURIComponent(state.platform || 'allgemein')}`, {
|
||
method: 'POST',
|
||
body: fd,
|
||
headers: { 'X-CSRF-Token': getCsrf() },
|
||
credentials: 'same-origin',
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.error || `Upload fehlgeschlagen (${response.status})`);
|
||
state.photos.push(...(data.filenames || []));
|
||
renderPhotoPreviews();
|
||
updateGenerateButton();
|
||
} catch (err) { toast('Upload fehlgeschlagen: ' + err.message); }
|
||
}
|
||
|
||
function renderPhotoPreviews() {
|
||
renderPhotoPreview();
|
||
}
|
||
|
||
function renderPhotoPreview() {
|
||
const c = document.getElementById('photo-preview');
|
||
c.innerHTML = '';
|
||
for (let i = 0; i < state.photos.length; i++) {
|
||
const div = document.createElement('div');
|
||
div.className = 'thumb';
|
||
div.draggable = true;
|
||
div.dataset.index = String(i + 1);
|
||
const safeUrl = photoUrl(state.photos[i]);
|
||
div.innerHTML = `<img src="${safeUrl}" alt="Foto ${i + 1}"><button class="remove-photo" aria-label="Foto entfernen">×</button><button class="edit-photo" title="Im Advanced Editor bearbeiten" aria-label="Im Advanced Editor bearbeiten">✎</button>`;
|
||
div.querySelector('img').addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
if (i > 0) {
|
||
const selected = state.photos.splice(i, 1)[0];
|
||
state.photos.unshift(selected);
|
||
renderPhotoPreview();
|
||
toast('Coverfoto geändert');
|
||
}
|
||
});
|
||
div.querySelector('.remove-photo').addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
state.photos.splice(i, 1);
|
||
renderPhotoPreview();
|
||
updateGenerateButton();
|
||
});
|
||
div.querySelector('.edit-photo').addEventListener('click', e => {
|
||
e.stopPropagation();
|
||
openPhotoEditor(i);
|
||
});
|
||
c.appendChild(div);
|
||
}
|
||
document.getElementById('image-tools').classList.toggle('hidden', state.photos.length === 0);
|
||
updateGeneratorPhotoStage();
|
||
}
|
||
|
||
function updateGenerateButton() {
|
||
const ready = state.photos.length > 0;
|
||
document.getElementById('generate-btn').disabled = !ready;
|
||
if (!ready) {
|
||
state.aiModelPhotos = [];
|
||
renderAiModelGrid('generator', []);
|
||
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
|
||
} else if (!state.aiModelPhotos.length) {
|
||
setAiModelStatus('generator-ai-model-status', 'Bereit für sichere AI-Model-Fotos.', 'neutral');
|
||
}
|
||
updateGeneratorReadiness();
|
||
}
|
||
|
||
// --- Image tools ---
|
||
function setupImageTools() {
|
||
document.getElementById('advanced-image-editor-btn')?.addEventListener('click', () => {
|
||
if (!state.photos.length) return toast('Bitte zuerst mindestens ein Foto hochladen.');
|
||
openPhotoEditor(0, 'generator');
|
||
});
|
||
}
|
||
|
||
// --- Generate ---
|
||
function setupGenerate() {
|
||
document.getElementById('generate-btn').addEventListener('click', generateListing);
|
||
document.getElementById('reset-generator-btn').addEventListener('click', resetGenerator);
|
||
document.getElementById('language-select').addEventListener('change', e => { state.language = e.target.value; updateGeneratorReadiness(); });
|
||
document.getElementById('variants-select').addEventListener('change', e => { state.variants = parseInt(e.target.value); });
|
||
document.getElementById('template-select').addEventListener('change', e => applyGeneratorTemplate(e.target.value));
|
||
document.getElementById('generator-ai-model-btn')?.addEventListener('click', runGeneratorAiModelPhotos);
|
||
document.getElementById('generator-ai-model-prompt-btn')?.addEventListener('click', () => openAiPromptModal('generator'));
|
||
document.getElementById('generator-ai-model-mode')?.addEventListener('change', () => updateAiModeControls('generator'));
|
||
updateAiModeControls('generator');
|
||
}
|
||
|
||
let generatorRenderSequence = 0;
|
||
|
||
function getGeneratorListingForHtml() {
|
||
const tags = (document.getElementById('result-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean);
|
||
return {
|
||
platform: state.platform,
|
||
title: document.getElementById('result-title')?.value || '',
|
||
description: editors.result ? editors.result.getText().trim() : '',
|
||
description_html: getEditorHtml(editors.result),
|
||
tags,
|
||
brand: document.getElementById('meta-brand')?.value || null,
|
||
category: metaCategoryPicker?.getValue?.() || null,
|
||
size: document.getElementById('meta-size')?.value || null,
|
||
color: document.getElementById('meta-color')?.value || null,
|
||
condition: document.getElementById('meta-condition')?.value || null,
|
||
};
|
||
}
|
||
|
||
const refreshGeneratorPlatformPreview = debounce(async () => {
|
||
if (!state.currentListing || document.getElementById('result-section')?.classList.contains('hidden')) return;
|
||
const sequence = ++generatorRenderSequence;
|
||
setHtmlSyncStatus('generator-html-sync-status', 'Wird aktualisiert …', 'working');
|
||
try {
|
||
const listing = getGeneratorListingForHtml();
|
||
const data = await api('POST', '/api/html-render', { platform: state.platform, listing });
|
||
if (sequence !== generatorRenderSequence) return;
|
||
state.currentHtml = data.html || listing.description_html;
|
||
setSandboxPreview(document.getElementById('html-preview'), state.currentHtml);
|
||
setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok');
|
||
} catch (err) {
|
||
if (sequence !== generatorRenderSequence) return;
|
||
setSandboxPreview(document.getElementById('html-preview'), getEditorHtml(editors.result));
|
||
setHtmlSyncStatus('generator-html-sync-status', 'Vorschau lokal', 'warning');
|
||
}
|
||
}, 140);
|
||
|
||
function setupGeneratorHtmlSync() {
|
||
const editor = editors.result;
|
||
const source = document.getElementById('html-source');
|
||
if (!editor || !source) return;
|
||
|
||
editor.on('text-change', () => {
|
||
if (htmlSyncState.generatorLock) return;
|
||
source.value = getEditorHtml(editor);
|
||
updateGeneratorResultMetrics();
|
||
refreshGeneratorPlatformPreview();
|
||
});
|
||
|
||
const applySourceToEditor = debounce(() => {
|
||
htmlSyncState.generatorLock = true;
|
||
const safe = sanitizeRichHtmlClient(source.value);
|
||
setEditorHtml(editor, safe);
|
||
htmlSyncState.generatorLock = false;
|
||
source.value = safe;
|
||
updateGeneratorResultMetrics();
|
||
refreshGeneratorPlatformPreview();
|
||
}, 100);
|
||
|
||
source.addEventListener('input', () => {
|
||
setHtmlSyncStatus('generator-html-sync-status', 'Code wird übernommen …', 'working');
|
||
setSandboxPreview(document.getElementById('html-preview'), sanitizeRichHtmlClient(source.value));
|
||
applySourceToEditor();
|
||
});
|
||
}
|
||
|
||
|
||
function setHtmlView(scope, view) {
|
||
document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => {
|
||
const active = button.dataset.htmlView === view;
|
||
button.classList.toggle('is-active', active);
|
||
button.setAttribute('aria-selected', active ? 'true' : 'false');
|
||
});
|
||
const root = scope === 'generator' ? document.getElementById('html-preview-wrap') : document.querySelector('.detail-platform-workbench');
|
||
root?.querySelectorAll('.html-switch-view').forEach(panel => panel.classList.toggle('is-active', panel.dataset.htmlPanel === view));
|
||
}
|
||
|
||
function bindHtmlViewTabs(scope) {
|
||
document.querySelectorAll(`.html-view-tab[data-html-scope="${scope}"]`).forEach(button => {
|
||
if (button.dataset.bound === '1') return;
|
||
button.dataset.bound = '1';
|
||
button.addEventListener('click', () => setHtmlView(scope, button.dataset.htmlView));
|
||
});
|
||
}
|
||
|
||
function setupHtmlViewSwitches() {
|
||
bindHtmlViewTabs('generator');
|
||
setHtmlView('generator', 'preview');
|
||
}
|
||
|
||
function setupMobileUpload() {
|
||
document.getElementById('generator-mobile-upload')?.addEventListener('click', () => openMobileUpload('generator'));
|
||
document.getElementById('mobile-upload-close')?.addEventListener('click', closeMobileUpload);
|
||
document.getElementById('mobile-upload-done')?.addEventListener('click', closeMobileUpload);
|
||
document.getElementById('mobile-upload-new-session')?.addEventListener('click', () => openMobileUpload(mobileUploadState.target?.context || 'generator', mobileUploadState.target?.listingId, true));
|
||
document.getElementById('mobile-upload-copy-link')?.addEventListener('click', async () => {
|
||
const input = document.getElementById('mobile-upload-link');
|
||
if (!input?.value) return;
|
||
await navigator.clipboard.writeText(input.value);
|
||
toast('Upload-Link kopiert');
|
||
});
|
||
document.getElementById('mobile-upload-overlay')?.addEventListener('click', event => {
|
||
if (event.target.id === 'mobile-upload-overlay') closeMobileUpload();
|
||
});
|
||
}
|
||
|
||
async function openMobileUpload(context = 'generator', listingId = null, renew = false) {
|
||
if (renew && mobileUploadState.session?.id) {
|
||
try { await api('DELETE', `/api/mobile-upload-sessions/${mobileUploadState.session.id}`); } catch {}
|
||
}
|
||
stopMobileUploadPolling();
|
||
mobileUploadState.target = { context, listingId: listingId || null };
|
||
mobileUploadState.imported = new Set(context === 'listing' ? (detailEditorState?.photos || []) : state.photos);
|
||
const overlay = document.getElementById('mobile-upload-overlay');
|
||
overlay?.classList.remove('hidden');
|
||
overlay?.setAttribute('aria-hidden', 'false');
|
||
setMobileUploadDialogState('Link wird vorbereitet …', 'working');
|
||
try {
|
||
const session = await api('POST', '/api/mobile-upload-sessions', { context, listing_id: listingId || null });
|
||
mobileUploadState.session = session;
|
||
const link = document.getElementById('mobile-upload-link');
|
||
if (link) link.value = session.url;
|
||
const qrCanvas = document.getElementById('mobile-upload-qr');
|
||
if (typeof QRious !== 'undefined' && qrCanvas) new QRious({ element: qrCanvas, value: session.url, size: 240, level: 'M', foreground: '#171714', background: '#ffffff' });
|
||
const expiry = new Date(session.expires_at);
|
||
document.getElementById('mobile-upload-expiry').textContent = `Gültig bis ${expiry.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||
const localOnly = /localhost|127\.0\.0\.1|0\.0\.0\.0/i.test(session.url);
|
||
const networkNote = document.getElementById('mobile-upload-network-note');
|
||
if (networkNote) {
|
||
networkNote.textContent = localOnly
|
||
? 'Keine LAN-IP erkannt. Prüfe Windows-Firewall und Netzwerkadapter oder setze PUBLIC_BASE_URL manuell.'
|
||
: `Handy und Vendoo-PC müssen im selben Netzwerk sein. Erkannte Adresse: ${session.base_url || session.url.split('/mobile-upload/')[0]}`;
|
||
}
|
||
setMobileUploadDialogState(localOnly ? 'LAN-Adresse prüfen' : 'Bereit zum Scannen', localOnly ? 'warning' : 'ready');
|
||
updateMobileUploadCount([]);
|
||
mobileUploadState.pollTimer = window.setInterval(pollMobileUploadSession, 1500);
|
||
} catch (error) {
|
||
setMobileUploadDialogState(error.message, 'error');
|
||
}
|
||
}
|
||
|
||
function setMobileUploadDialogState(text, tone) {
|
||
const element = document.getElementById('mobile-upload-session-state');
|
||
if (!element) return;
|
||
element.lastChild.textContent = text;
|
||
element.dataset.tone = tone;
|
||
}
|
||
|
||
function updateMobileUploadCount(files) {
|
||
const count = document.getElementById('mobile-upload-file-count');
|
||
if (count) count.textContent = `${files.length} ${files.length === 1 ? 'Bild' : 'Bilder'} empfangen`;
|
||
}
|
||
|
||
async function pollMobileUploadSession() {
|
||
const session = mobileUploadState.session;
|
||
if (!session?.id) return;
|
||
try {
|
||
const data = await api('GET', `/api/mobile-upload-sessions/${session.id}`);
|
||
const files = Array.isArray(data.files) ? data.files : [];
|
||
const fresh = files.filter(file => !mobileUploadState.imported.has(file));
|
||
fresh.forEach(file => mobileUploadState.imported.add(file));
|
||
if (fresh.length) {
|
||
if (mobileUploadState.target?.context === 'listing' && detailEditorState) {
|
||
detailEditorState.photos = [...new Set([...detailEditorState.photos, ...fresh])].slice(0, 10);
|
||
renderDetailPhotos();
|
||
} else {
|
||
state.photos = [...new Set([...state.photos, ...fresh])].slice(0, 10);
|
||
renderPhotoPreviews();
|
||
updateGeneratorReadiness();
|
||
}
|
||
toast(`${fresh.length} ${fresh.length === 1 ? 'Bild empfangen' : 'Bilder empfangen'}`);
|
||
}
|
||
updateMobileUploadCount(files);
|
||
if (files.length) setMobileUploadDialogState('Bilder werden live übertragen', 'ready');
|
||
} catch (error) {
|
||
setMobileUploadDialogState(error.message, 'error');
|
||
stopMobileUploadPolling();
|
||
}
|
||
}
|
||
|
||
function stopMobileUploadPolling() {
|
||
if (mobileUploadState.pollTimer) window.clearInterval(mobileUploadState.pollTimer);
|
||
mobileUploadState.pollTimer = null;
|
||
}
|
||
|
||
function closeMobileUpload() {
|
||
stopMobileUploadPolling();
|
||
const overlay = document.getElementById('mobile-upload-overlay');
|
||
overlay?.classList.add('hidden');
|
||
overlay?.setAttribute('aria-hidden', 'true');
|
||
}
|
||
|
||
function setupGeneratorStudio() {
|
||
const notes = document.getElementById('generator-seller-notes');
|
||
notes?.addEventListener('input', () => {
|
||
const count = document.getElementById('generator-notes-count');
|
||
if (count) count.textContent = `${notes.value.length} / 500`;
|
||
});
|
||
document.getElementById('result-title')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); });
|
||
document.getElementById('result-tags')?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); });
|
||
document.getElementById('meta-price')?.addEventListener('input', debounce(updateGeneratorFeePreview, 250));
|
||
document.getElementById('generator-download-photos')?.addEventListener('click', () => downloadPublishPhotos(state.photos));
|
||
['meta-brand', 'meta-size', 'meta-color', 'meta-condition'].forEach(id => {
|
||
document.getElementById(id)?.addEventListener('input', () => { updateGeneratorResultMetrics(); refreshGeneratorPlatformPreview(); });
|
||
});
|
||
updateGeneratorPhotoStage();
|
||
updateGeneratorReadiness();
|
||
updateGeneratorResultMetrics();
|
||
}
|
||
|
||
function applyGeneratorTemplate(value) {
|
||
const tpl = state.templates.find(t => t.id === parseInt(value));
|
||
const notes = document.getElementById('generator-seller-notes');
|
||
const tags = document.getElementById('generator-template-tags');
|
||
if (!tpl) {
|
||
if (notes) notes.value = '';
|
||
if (tags) { tags.innerHTML = ''; tags.classList.add('hidden'); }
|
||
const count = document.getElementById('generator-notes-count');
|
||
if (count) count.textContent = '0 / 500';
|
||
return;
|
||
}
|
||
if (tpl.platform) {
|
||
state.platform = tpl.platform;
|
||
renderPlatformButtons();
|
||
}
|
||
if (tpl.language) {
|
||
state.language = tpl.language;
|
||
document.getElementById('language-select').value = tpl.language;
|
||
}
|
||
if (notes) notes.value = (tpl.seller_notes || '').slice(0, 500);
|
||
const count = document.getElementById('generator-notes-count');
|
||
if (count) count.textContent = `${notes?.value.length || 0} / 500`;
|
||
const defaultTags = Array.isArray(tpl.default_tags) ? tpl.default_tags : [];
|
||
if (tags) {
|
||
tags.innerHTML = defaultTags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('');
|
||
tags.classList.toggle('hidden', defaultTags.length === 0);
|
||
}
|
||
updateGeneratorReadiness();
|
||
}
|
||
|
||
function updateGeneratorPhotoStage() {
|
||
const count = state.photos.length;
|
||
const cover = document.getElementById('generator-cover');
|
||
const empty = document.getElementById('generator-photo-empty');
|
||
const badge = document.getElementById('generator-cover-badge');
|
||
const countEl = document.getElementById('generator-photo-count');
|
||
if (countEl) countEl.textContent = `${count} / 10`;
|
||
if (cover && empty && badge) {
|
||
if (count) {
|
||
cover.src = photoUrl(state.photos[0]);
|
||
cover.classList.remove('hidden');
|
||
empty.classList.add('hidden');
|
||
badge.classList.remove('hidden');
|
||
} else {
|
||
cover.removeAttribute('src');
|
||
cover.classList.add('hidden');
|
||
empty.classList.remove('hidden');
|
||
badge.classList.add('hidden');
|
||
}
|
||
}
|
||
const completed = {
|
||
count: count >= 3,
|
||
cover: count >= 1,
|
||
details: count >= 4,
|
||
background: count >= 2,
|
||
order: count >= 2,
|
||
};
|
||
let score = 0;
|
||
document.querySelectorAll('#generator-photo-tips [data-photo-tip]').forEach(item => {
|
||
const done = Boolean(completed[item.dataset.photoTip]);
|
||
item.classList.toggle('is-complete', done);
|
||
if (done) score += 1;
|
||
});
|
||
const scoreEl = document.getElementById('generator-photo-quality-score');
|
||
if (scoreEl) scoreEl.textContent = `${score} / 5`;
|
||
document.getElementById('generator-step-photos')?.classList.toggle('is-complete', count > 0);
|
||
}
|
||
|
||
function updateGeneratorReadiness() {
|
||
const wrapper = document.querySelector('.generator-readiness');
|
||
const title = document.getElementById('generator-readiness-title');
|
||
const copy = document.getElementById('generator-readiness-copy');
|
||
const ready = state.photos.length > 0;
|
||
wrapper?.classList.toggle('is-ready', ready);
|
||
if (title) title.textContent = ready ? 'Bereit zur Analyse' : 'Fotos fehlen';
|
||
if (copy) copy.textContent = ready
|
||
? `${state.photos.length} Foto${state.photos.length === 1 ? '' : 's'} · ${platformLabel(state.platform)} · ${state.language.toUpperCase()}`
|
||
: 'Lade mindestens ein Produktfoto hoch.';
|
||
document.getElementById('generator-step-ai')?.classList.toggle('is-active', ready && !state.currentListing);
|
||
}
|
||
|
||
function updateGeneratorResultMetrics() {
|
||
const title = document.getElementById('result-title')?.value || '';
|
||
const description = editors.result ? editors.result.getText().trim() : '';
|
||
const tagValue = document.getElementById('result-tags')?.value || '';
|
||
const tags = tagValue.split(',').map(tag => tag.trim()).filter(Boolean);
|
||
const titleCount = document.getElementById('result-title-count');
|
||
const descriptionCount = document.getElementById('result-description-count');
|
||
const tagsCount = document.getElementById('result-tags-count');
|
||
if (titleCount) titleCount.textContent = `${title.length} / 80`;
|
||
if (descriptionCount) descriptionCount.textContent = `${description.length} Zeichen`;
|
||
if (tagsCount) tagsCount.textContent = `${tags.length} Tag${tags.length === 1 ? '' : 's'}`;
|
||
|
||
let score = 0;
|
||
if (title.length >= 30 && title.length <= 80) score += 25;
|
||
else if (title.length >= 15) score += 16;
|
||
if (description.length >= 180) score += 30;
|
||
else if (description.length >= 80) score += 18;
|
||
if (tags.length >= 5) score += 15;
|
||
else if (tags.length) score += 8;
|
||
if (document.getElementById('meta-brand')?.value) score += 8;
|
||
if (document.getElementById('meta-category-picker')?.textContent?.trim()) score += 8;
|
||
if (parseFloat(document.getElementById('meta-price')?.value)) score += 8;
|
||
if (state.photos.length >= 3) score += 6;
|
||
score = Math.min(100, score);
|
||
const scoreEl = document.getElementById('generator-quality-score');
|
||
const ring = document.getElementById('generator-quality-ring');
|
||
const label = document.getElementById('generator-quality-label');
|
||
if (scoreEl) scoreEl.textContent = `${score}%`;
|
||
if (ring) ring.style.setProperty('--quality', `${score}%`);
|
||
if (label) {
|
||
label.textContent = score >= 85 ? 'Sehr gut' : score >= 65 ? 'Gut' : score >= 40 ? 'Ausbaufähig' : 'Unvollständig';
|
||
label.style.color = score >= 65 ? 'var(--vd-success)' : score >= 40 ? 'var(--vd-warning)' : 'var(--vd-danger)';
|
||
}
|
||
}
|
||
|
||
async function updateGeneratorFeePreview() {
|
||
const price = parseFloat(document.getElementById('meta-price')?.value) || 0;
|
||
const feeEl = document.getElementById('fee-info');
|
||
if (!feeEl || !price) {
|
||
feeEl?.classList.add('hidden');
|
||
updateGeneratorResultMetrics();
|
||
return;
|
||
}
|
||
try {
|
||
const fees = await api('GET', `/api/fees?platform=${encodeURIComponent(state.platform)}&price=${encodeURIComponent(price)}`);
|
||
feeEl.innerHTML = `Gebühren: ${fees.fees} € (${escapeHtml(fees.breakdown || '')}) → <span class="fee-net">Netto: ${fees.net} €</span>`;
|
||
feeEl.classList.remove('hidden');
|
||
} catch {}
|
||
updateGeneratorResultMetrics();
|
||
}
|
||
|
||
async function generateListing() {
|
||
const btn = document.getElementById('generate-btn');
|
||
const loading = document.getElementById('loading');
|
||
|
||
btn.disabled = true;
|
||
document.getElementById('result-section').classList.add('hidden');
|
||
document.getElementById('meta-section').classList.add('hidden');
|
||
document.getElementById('variants-section').classList.add('hidden');
|
||
document.getElementById('generator-preview-empty')?.classList.remove('hidden');
|
||
const previewStatus = document.getElementById('generator-preview-status');
|
||
if (previewStatus) { previewStatus.textContent = 'Analysiert'; previewStatus.className = 'generator-preview-status is-working'; }
|
||
document.getElementById('generator-step-ai')?.classList.add('is-active');
|
||
loading.classList.remove('hidden');
|
||
|
||
try {
|
||
const body = {
|
||
photos: state.photos,
|
||
platform: state.platform,
|
||
ai_provider: state.aiProvider,
|
||
language: state.language,
|
||
variants: state.variants,
|
||
template_id: parseInt(document.getElementById('template-select')?.value) || null,
|
||
seller_notes: document.getElementById('generator-seller-notes')?.value.trim() || '',
|
||
};
|
||
if (state.aiModel) body.ai_model = state.aiModel;
|
||
|
||
const data = await api('POST', '/api/generate', body);
|
||
|
||
if (data.meta) showMeta(data.meta, data.fees);
|
||
|
||
if (data.variants) {
|
||
state.allVariants = data.variants;
|
||
showVariantTabs(data.variants);
|
||
showListing(data.variants[0], data.variants[0].description_html);
|
||
} else {
|
||
state.allVariants = null;
|
||
document.getElementById('variants-section').classList.add('hidden');
|
||
showListing(data.listing, data.description_html);
|
||
}
|
||
setTimeout(fetchPriceSuggestion, 500);
|
||
} catch (err) {
|
||
const previewStatus = document.getElementById('generator-preview-status');
|
||
if (previewStatus) { previewStatus.textContent = 'Fehler'; previewStatus.className = 'generator-preview-status'; }
|
||
document.getElementById('generator-step-ai')?.classList.remove('is-active');
|
||
toast('Fehler: ' + err.message);
|
||
} finally {
|
||
loading.classList.add('hidden');
|
||
btn.disabled = state.photos.length === 0;
|
||
updateGeneratorReadiness();
|
||
}
|
||
}
|
||
|
||
async function showMeta(meta, fees) {
|
||
document.getElementById('meta-sku').value = state.currentListing?.sku || '';
|
||
document.getElementById('meta-brand').value = meta.brand || '';
|
||
document.getElementById('meta-size').value = meta.size || '';
|
||
document.getElementById('meta-color').value = meta.color || '';
|
||
document.getElementById('meta-condition').value = meta.condition || '';
|
||
document.getElementById('meta-price').value = meta.suggested_price || '';
|
||
document.getElementById('meta-section').classList.remove('hidden');
|
||
updateGeneratorResultMetrics();
|
||
|
||
let matchedCategory = null;
|
||
if (meta.category) {
|
||
try {
|
||
const matches = await api('GET', `/api/categories/${state.platform}/match?q=${encodeURIComponent(meta.category)}`);
|
||
if (matches.length && matches[0].score >= 50) matchedCategory = matches[0].pathStr;
|
||
} catch {}
|
||
}
|
||
metaCategoryPicker = createCategoryPicker('meta-category-picker', state.platform, matchedCategory || meta.category || null);
|
||
|
||
const feeEl = document.getElementById('fee-info');
|
||
if (fees) {
|
||
feeEl.innerHTML = `Gebühren: ${fees.fees} € (${fees.breakdown}) → <span class="fee-net">Netto: ${fees.net} €</span>`;
|
||
feeEl.classList.remove('hidden');
|
||
} else { feeEl.classList.add('hidden'); }
|
||
}
|
||
|
||
function getEditedMeta() {
|
||
return {
|
||
brand: document.getElementById('meta-brand').value || null,
|
||
category: (metaCategoryPicker ? metaCategoryPicker.getValue() : null) || null,
|
||
size: document.getElementById('meta-size').value || null,
|
||
color: document.getElementById('meta-color').value || null,
|
||
condition: document.getElementById('meta-condition').value || null,
|
||
price: parseFloat(document.getElementById('meta-price').value) || null,
|
||
storage_location: document.getElementById('meta-storage-location').value || null,
|
||
};
|
||
}
|
||
|
||
function showVariantTabs(variants) {
|
||
const c = document.getElementById('variant-tabs');
|
||
c.innerHTML = '';
|
||
variants.forEach((v, i) => {
|
||
const btn = document.createElement('button');
|
||
btn.className = 'variant-tab' + (i === 0 ? ' active' : '');
|
||
btn.textContent = `Variante ${i + 1}`;
|
||
btn.addEventListener('click', () => {
|
||
c.querySelectorAll('.variant-tab').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
showListing(v, v.description_html);
|
||
});
|
||
c.appendChild(btn);
|
||
});
|
||
document.getElementById('variants-section').classList.remove('hidden');
|
||
}
|
||
|
||
function showListing(listing, platformHtml) {
|
||
state.currentListing = listing;
|
||
const editorHtml = listing.id && listing.description_html
|
||
? sanitizeRichHtmlClient(listing.description_html)
|
||
: plainTextToRichHtml(listing.description || '');
|
||
state.currentHtml = platformHtml || '';
|
||
|
||
const skuEl = document.getElementById('meta-sku');
|
||
if (skuEl && listing.sku) skuEl.value = listing.sku;
|
||
document.getElementById('result-title').value = listing.title || '';
|
||
htmlSyncState.generatorLock = true;
|
||
setEditorHtml(editors.result, editorHtml);
|
||
htmlSyncState.generatorLock = false;
|
||
document.getElementById('html-source').value = getEditorHtml(editors.result);
|
||
setSandboxPreview(document.getElementById('html-preview'), state.currentHtml || editorHtml);
|
||
document.getElementById('html-preview-wrap')?.classList.remove('hidden');
|
||
setHtmlSyncStatus('generator-html-sync-status', 'Synchron', 'ok');
|
||
|
||
const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || '');
|
||
document.getElementById('result-tags').value = tags;
|
||
const plat = state.platforms.find(p => p.id === state.platform);
|
||
document.getElementById('tags-label').textContent = plat?.fields.includes('hashtags') ? 'Hashtags' : 'Tags';
|
||
const saveButton = document.getElementById('save-btn');
|
||
if (saveButton) { saveButton.textContent = listing.id ? 'Änderungen speichern' : 'Artikel speichern'; saveButton.disabled = false; }
|
||
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');
|
||
const saveButton = document.getElementById('save-btn');
|
||
if (saveButton) { saveButton.textContent = 'Speichern'; saveButton.disabled = true; }
|
||
document.getElementById('generate-btn').disabled = true;
|
||
renderAiModelGrid('generator', []);
|
||
setAiModelStatus('generator-ai-model-status', 'Wartet auf Produktfotos.', 'neutral');
|
||
document.getElementById('template-select').value = '';
|
||
const notes = document.getElementById('generator-seller-notes');
|
||
if (notes) notes.value = '';
|
||
const notesCount = document.getElementById('generator-notes-count');
|
||
if (notesCount) notesCount.textContent = '0 / 500';
|
||
const templateTags = document.getElementById('generator-template-tags');
|
||
if (templateTags) { templateTags.innerHTML = ''; templateTags.classList.add('hidden'); }
|
||
if (editors.result) editors.result.setText('');
|
||
const htmlSource = document.getElementById('html-source');
|
||
if (htmlSource) htmlSource.value = '';
|
||
setSandboxPreview(document.getElementById('html-preview'), '');
|
||
document.getElementById('result-title').value = '';
|
||
document.getElementById('result-tags').value = '';
|
||
const previewStatus = document.getElementById('generator-preview-status');
|
||
if (previewStatus) { previewStatus.textContent = 'Wartet'; previewStatus.className = 'generator-preview-status'; }
|
||
['generator-step-ai', 'generator-step-result'].forEach(id => {
|
||
document.getElementById(id)?.classList.remove('is-active', 'is-complete');
|
||
});
|
||
document.getElementById('generator-step-photos')?.classList.remove('is-complete');
|
||
document.getElementById('generator-step-photos')?.classList.add('is-active');
|
||
updateGeneratorPhotoStage();
|
||
updateGeneratorReadiness();
|
||
updateGeneratorResultMetrics();
|
||
}
|
||
|
||
// --- Listings / Historie ---
|
||
const historyState = {
|
||
selected: new Set(),
|
||
listings: [],
|
||
allFiltered: [],
|
||
publishStatus: {},
|
||
hiddenColumns: new Set(),
|
||
};
|
||
|
||
const HISTORY_COLUMN_STORAGE = 'vendoo-history-columns';
|
||
|
||
function setupHistory() {
|
||
const search = document.getElementById('search-input');
|
||
const platform = document.getElementById('filter-platform');
|
||
const status = document.getElementById('filter-status');
|
||
const period = document.getElementById('history-period');
|
||
const dateFrom = document.getElementById('filter-date-from');
|
||
const dateTo = document.getElementById('filter-date-to');
|
||
|
||
search.addEventListener('input', debounce(() => { state.historyPage = 1; loadHistory(); }, 300));
|
||
platform.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
|
||
status.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
|
||
period.addEventListener('change', () => {
|
||
applyHistoryPeriod(period.value);
|
||
state.historyPage = 1;
|
||
loadHistory();
|
||
});
|
||
dateFrom.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
|
||
dateTo.addEventListener('change', () => { state.historyPage = 1; loadHistory(); });
|
||
|
||
document.getElementById('history-reset-filters').addEventListener('click', () => {
|
||
search.value = '';
|
||
platform.value = '';
|
||
status.value = '';
|
||
period.value = '90';
|
||
applyHistoryPeriod('90');
|
||
state.historyPage = 1;
|
||
loadHistory();
|
||
});
|
||
|
||
document.getElementById('back-to-list').addEventListener('click', async () => { await releaseListingLock({ quiet: true }); setHistoryDetailMode(false); });
|
||
|
||
document.getElementById('history-select-all').addEventListener('change', e => {
|
||
const checked = e.target.checked;
|
||
historyState.listings.forEach(listing => {
|
||
if (checked) historyState.selected.add(listing.id);
|
||
else historyState.selected.delete(listing.id);
|
||
});
|
||
document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = checked; });
|
||
document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.toggle('selected', checked));
|
||
updateHistoryToolbar();
|
||
});
|
||
|
||
document.getElementById('history-clear-selection').addEventListener('click', () => {
|
||
historyState.selected.clear();
|
||
document.querySelectorAll('#history-list .history-row-select').forEach(cb => { cb.checked = false; });
|
||
document.querySelectorAll('#history-list .history-row').forEach(row => row.classList.remove('selected'));
|
||
document.getElementById('history-select-all').checked = false;
|
||
updateHistoryToolbar();
|
||
});
|
||
|
||
document.getElementById('history-delete-selected').addEventListener('click', deleteSelectedHistoryListings);
|
||
document.getElementById('history-delete-all').addEventListener('click', async () => {
|
||
if (!historyState.allFiltered.length) return;
|
||
if (!confirm(`Alle ${historyState.allFiltered.length} gefilterten Artikel in den Papierkorb verschieben?`)) return;
|
||
for (const listing of historyState.allFiltered) await api('DELETE', `/api/listings/${listing.id}`);
|
||
historyState.selected.clear();
|
||
toast('Alle gefilterten Artikel wurden in den Papierkorb verschoben');
|
||
await loadHistory();
|
||
updateTrashBadge();
|
||
});
|
||
|
||
document.getElementById('history-bulk-status').addEventListener('click', bulkUpdateHistoryStatus);
|
||
document.getElementById('history-price-selected').addEventListener('click', bulkUpdateHistoryPrice);
|
||
document.getElementById('history-duplicate-selected').addEventListener('click', bulkDuplicateHistoryListings);
|
||
document.getElementById('history-edit-selected').addEventListener('click', () => {
|
||
const ids = [...historyState.selected];
|
||
if (ids.length !== 1) { toast('Zum Nachbearbeiten bitte genau ein Artikel auswählen'); return; }
|
||
showDetail(ids[0]);
|
||
});
|
||
document.getElementById('history-publish-selected').addEventListener('click', () => {
|
||
const ids = [...historyState.selected];
|
||
if (!ids.length) return;
|
||
publishState.selected = new Set(ids);
|
||
switchToTab('publish');
|
||
toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} für Publish ausgewählt`);
|
||
});
|
||
document.getElementById('history-print-labels').addEventListener('click', () => {
|
||
const selected = historyState.allFiltered.filter(listing => historyState.selected.has(listing.id));
|
||
if (selected.length) printLabels(selected);
|
||
});
|
||
|
||
document.getElementById('export-csv-btn').addEventListener('click', () => {
|
||
const params = new URLSearchParams();
|
||
if (platform.value) params.set('platform', platform.value);
|
||
if (status.value) params.set('status', status.value);
|
||
window.open(`/api/export/csv?${params}`, '_blank');
|
||
closeHistoryPopovers();
|
||
});
|
||
document.getElementById('export-json-btn').addEventListener('click', () => {
|
||
const data = historyState.allFiltered;
|
||
if (!data.length) { toast('Keine Artikel zum Exportieren'); return; }
|
||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||
const anchor = document.createElement('a');
|
||
anchor.href = URL.createObjectURL(blob);
|
||
anchor.download = `vendoo-export-${new Date().toISOString().slice(0, 10)}.json`;
|
||
anchor.click();
|
||
URL.revokeObjectURL(anchor.href);
|
||
toast(`${data.length} Artikel als JSON exportiert`);
|
||
closeHistoryPopovers();
|
||
});
|
||
|
||
setupHistoryPopovers();
|
||
setupHistoryColumns();
|
||
|
||
const pageSizeSelect = document.getElementById('history-page-size-select');
|
||
pageSizeSelect.value = String(state.historyPageSize);
|
||
pageSizeSelect.addEventListener('change', () => {
|
||
state.historyPageSize = Number(pageSizeSelect.value);
|
||
state.historyPage = 1;
|
||
loadHistory();
|
||
});
|
||
|
||
setupPagination('history');
|
||
applyHistoryPeriod(period.value || '90');
|
||
}
|
||
|
||
function applyHistoryPeriod(value) {
|
||
const custom = document.getElementById('history-custom-dates');
|
||
const fromInput = document.getElementById('filter-date-from');
|
||
const toInput = document.getElementById('filter-date-to');
|
||
custom.classList.toggle('hidden', value !== 'custom');
|
||
if (value === 'custom') return;
|
||
|
||
const today = new Date();
|
||
const iso = date => date.toISOString().slice(0, 10);
|
||
toInput.value = value === 'all' ? '' : iso(today);
|
||
|
||
if (value === 'all') {
|
||
fromInput.value = '';
|
||
} else if (value === 'year') {
|
||
fromInput.value = `${today.getFullYear()}-01-01`;
|
||
} else {
|
||
const days = Number(value) || 90;
|
||
const from = new Date(today);
|
||
from.setDate(from.getDate() - days + 1);
|
||
fromInput.value = iso(from);
|
||
}
|
||
}
|
||
|
||
function setupHistoryPopovers() {
|
||
const exportButton = document.getElementById('history-export-toggle');
|
||
const exportMenu = document.getElementById('history-export-menu');
|
||
const columnsButton = document.getElementById('history-columns-btn');
|
||
const columnsPanel = document.getElementById('history-columns-panel');
|
||
|
||
exportButton.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
const open = exportMenu.classList.toggle('hidden') === false;
|
||
columnsPanel.classList.add('hidden');
|
||
exportButton.setAttribute('aria-expanded', String(open));
|
||
columnsButton.setAttribute('aria-expanded', 'false');
|
||
});
|
||
columnsButton.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
const open = columnsPanel.classList.toggle('hidden') === false;
|
||
exportMenu.classList.add('hidden');
|
||
columnsButton.setAttribute('aria-expanded', String(open));
|
||
exportButton.setAttribute('aria-expanded', 'false');
|
||
});
|
||
exportMenu.addEventListener('click', event => event.stopPropagation());
|
||
columnsPanel.addEventListener('click', event => event.stopPropagation());
|
||
document.addEventListener('click', closeHistoryPopovers);
|
||
}
|
||
|
||
function closeHistoryPopovers() {
|
||
document.getElementById('history-export-menu')?.classList.add('hidden');
|
||
document.getElementById('history-columns-panel')?.classList.add('hidden');
|
||
document.getElementById('history-export-toggle')?.setAttribute('aria-expanded', 'false');
|
||
document.getElementById('history-columns-btn')?.setAttribute('aria-expanded', 'false');
|
||
document.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden'));
|
||
}
|
||
|
||
function setupHistoryColumns() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(HISTORY_COLUMN_STORAGE) || '[]');
|
||
historyState.hiddenColumns = new Set(Array.isArray(saved) ? saved : []);
|
||
} catch { historyState.hiddenColumns = new Set(); }
|
||
|
||
document.querySelectorAll('[data-history-column]').forEach(input => {
|
||
input.checked = !historyState.hiddenColumns.has(input.dataset.historyColumn);
|
||
input.addEventListener('change', () => {
|
||
const column = input.dataset.historyColumn;
|
||
if (input.checked) historyState.hiddenColumns.delete(column);
|
||
else historyState.hiddenColumns.add(column);
|
||
localStorage.setItem(HISTORY_COLUMN_STORAGE, JSON.stringify([...historyState.hiddenColumns]));
|
||
applyHistoryColumns();
|
||
});
|
||
});
|
||
applyHistoryColumns();
|
||
}
|
||
|
||
function applyHistoryColumns() {
|
||
const table = document.getElementById('history-table');
|
||
if (!table) return;
|
||
['platform', 'provider', 'price', 'status', 'location', 'published'].forEach(column => {
|
||
table.classList.toggle(`history-hide-${column}`, historyState.hiddenColumns.has(column));
|
||
});
|
||
}
|
||
|
||
function setHistoryDetailMode(enabled) {
|
||
document.querySelector('.listings-heading')?.classList.toggle('hidden', enabled);
|
||
document.querySelector('.history-filterbar')?.classList.toggle('hidden', enabled);
|
||
document.querySelector('.history-table-shell')?.classList.toggle('hidden', enabled);
|
||
document.getElementById('history-detail')?.classList.toggle('hidden', !enabled);
|
||
}
|
||
|
||
async function deleteSelectedHistoryListings() {
|
||
const ids = [...historyState.selected];
|
||
if (!ids.length) return;
|
||
if (!confirm(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschieben?`)) return;
|
||
for (const id of ids) await api('DELETE', `/api/listings/${id}`);
|
||
historyState.selected.clear();
|
||
toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} in den Papierkorb verschoben`);
|
||
await loadHistory();
|
||
updateTrashBadge();
|
||
}
|
||
|
||
async function bulkUpdateHistoryStatus() {
|
||
const ids = [...historyState.selected];
|
||
if (!ids.length) return;
|
||
const answer = prompt('Neuer Status: aktiv, verkauft oder reserviert');
|
||
if (!answer) return;
|
||
const normalized = answer.trim().toLowerCase();
|
||
const map = { aktiv: 'active', active: 'active', verkauft: 'sold', sold: 'sold', reserviert: 'reserved', reserved: 'reserved' };
|
||
const newStatus = map[normalized];
|
||
if (!newStatus) { toast('Unbekannter Status'); return; }
|
||
await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { status: newStatus })));
|
||
toast(`Status für ${ids.length} Artikel aktualisiert`);
|
||
loadHistory();
|
||
}
|
||
|
||
async function bulkUpdateHistoryPrice() {
|
||
const ids = [...historyState.selected];
|
||
if (!ids.length) return;
|
||
const answer = prompt('Neuer Preis in Euro für alle ausgewählten Artikel:');
|
||
if (answer === null) return;
|
||
const price = Number(String(answer).replace(',', '.'));
|
||
if (!Number.isFinite(price) || price < 0) { toast('Bitte einen gültigen Preis eingeben'); return; }
|
||
await Promise.all(ids.map(id => api('PUT', `/api/listings/${id}`, { price })));
|
||
toast(`Preis für ${ids.length} Artikel aktualisiert`);
|
||
loadHistory();
|
||
}
|
||
|
||
async function bulkDuplicateHistoryListings() {
|
||
const ids = [...historyState.selected];
|
||
if (!ids.length) return;
|
||
for (const id of ids) await api('POST', `/api/listings/${id}/duplicate`);
|
||
historyState.selected.clear();
|
||
toast(`${ids.length} Listing${ids.length === 1 ? '' : 's'} dupliziert`);
|
||
loadHistory();
|
||
}
|
||
|
||
function setupPagination(prefix) {
|
||
const topBar = document.getElementById(`${prefix}-pagination-top`);
|
||
if (!topBar) return;
|
||
topBar.querySelectorAll('.page-size-btn').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
topBar.querySelectorAll('.page-size-btn').forEach(b => b.classList.remove('active'));
|
||
btn.classList.add('active');
|
||
const size = parseInt(btn.dataset.size);
|
||
if (prefix === 'history') { state.historyPageSize = size; state.historyPage = 1; loadHistory(); }
|
||
else { state.publishPageSize = size; state.publishPage = 1; loadPublishList(); }
|
||
});
|
||
});
|
||
document.getElementById(`${prefix}-prev`)?.addEventListener('click', () => {
|
||
if (prefix === 'history') { state.historyPage--; loadHistory(); }
|
||
else { state.publishPage--; loadPublishList(); }
|
||
});
|
||
document.getElementById(`${prefix}-next`)?.addEventListener('click', () => {
|
||
if (prefix === 'history') { state.historyPage++; loadHistory(); }
|
||
else { state.publishPage++; loadPublishList(); }
|
||
});
|
||
document.getElementById(`${prefix}-prev-bottom`)?.addEventListener('click', () => {
|
||
if (prefix === 'history') { state.historyPage--; loadHistory(); }
|
||
else { state.publishPage--; loadPublishList(); }
|
||
});
|
||
document.getElementById(`${prefix}-next-bottom`)?.addEventListener('click', () => {
|
||
if (prefix === 'history') { state.historyPage++; loadHistory(); }
|
||
else { state.publishPage++; loadPublishList(); }
|
||
});
|
||
}
|
||
|
||
function updatePaginationUI(prefix, page, totalPages, totalItems) {
|
||
const info = `Seite ${page} von ${totalPages} (${totalItems} Einträge)`;
|
||
document.getElementById(`${prefix}-page-info`)?.replaceChildren(document.createTextNode(info));
|
||
document.getElementById(`${prefix}-page-info-bottom`)?.replaceChildren(document.createTextNode(info));
|
||
|
||
const prevDisabled = page <= 1;
|
||
const nextDisabled = page >= totalPages;
|
||
const prev = document.getElementById(`${prefix}-prev`);
|
||
const next = document.getElementById(`${prefix}-next`);
|
||
const prevBottom = document.getElementById(`${prefix}-prev-bottom`);
|
||
const nextBottom = document.getElementById(`${prefix}-next-bottom`);
|
||
if (prev) prev.disabled = prevDisabled;
|
||
if (next) next.disabled = nextDisabled;
|
||
if (prevBottom) prevBottom.disabled = prevDisabled;
|
||
if (nextBottom) nextBottom.disabled = nextDisabled;
|
||
|
||
if (prefix === 'history') {
|
||
const totalInfo = document.getElementById('history-total-info');
|
||
if (totalInfo) totalInfo.textContent = `von ${totalItems} Einträgen`;
|
||
renderHistoryPageButtons(page, totalPages);
|
||
}
|
||
}
|
||
|
||
function renderHistoryPageButtons(page, totalPages) {
|
||
const container = document.getElementById('history-page-buttons');
|
||
if (!container) return;
|
||
const pages = new Set([1, totalPages, page - 2, page - 1, page, page + 1, page + 2]);
|
||
const valid = [...pages].filter(value => value >= 1 && value <= totalPages).sort((a, b) => a - b);
|
||
const parts = [];
|
||
let previous = 0;
|
||
for (const value of valid) {
|
||
if (previous && value - previous > 1) parts.push('<span class="history-page-gap">…</span>');
|
||
parts.push(`<button class="history-page-button${value === page ? ' active' : ''}" type="button" data-page="${value}"${value === page ? ' aria-current="page"' : ''}>${value}</button>`);
|
||
previous = value;
|
||
}
|
||
container.innerHTML = parts.join('');
|
||
container.querySelectorAll('[data-page]').forEach(button => button.addEventListener('click', () => {
|
||
state.historyPage = Number(button.dataset.page);
|
||
loadHistory();
|
||
}));
|
||
}
|
||
|
||
function paginateList(allItems, page, pageSize) {
|
||
if (!pageSize || pageSize <= 0) return { items: allItems, page: 1, totalPages: 1 };
|
||
const totalPages = Math.max(1, Math.ceil(allItems.length / pageSize));
|
||
const safePage = Math.min(Math.max(1, page), totalPages);
|
||
const start = (safePage - 1) * pageSize;
|
||
return { items: allItems.slice(start, start + pageSize), page: safePage, totalPages };
|
||
}
|
||
|
||
async function loadHistory() {
|
||
const query = document.getElementById('search-input').value.trim();
|
||
const platform = document.getElementById('filter-platform').value;
|
||
const status = document.getElementById('filter-status').value;
|
||
const dateFrom = document.getElementById('filter-date-from').value;
|
||
const dateTo = document.getElementById('filter-date-to').value;
|
||
|
||
const params = new URLSearchParams();
|
||
if (query) params.set('q', query);
|
||
if (platform) params.set('platform', platform);
|
||
if (status) params.set('status', status);
|
||
let listings = await api('GET', `/api/listings?${params.toString()}`);
|
||
|
||
if (dateFrom) {
|
||
const from = new Date(`${dateFrom}T00:00:00`);
|
||
listings = listings.filter(listing => historyParseDate(listing.created_at) >= from);
|
||
}
|
||
if (dateTo) {
|
||
const to = new Date(`${dateTo}T23:59:59.999`);
|
||
listings = listings.filter(listing => historyParseDate(listing.created_at) <= to);
|
||
}
|
||
|
||
historyState.allFiltered = listings;
|
||
const { items, page, totalPages } = paginateList(listings, state.historyPage, state.historyPageSize);
|
||
state.historyPage = page;
|
||
historyState.listings = items;
|
||
updatePaginationUI('history', page, totalPages, listings.length);
|
||
|
||
if (items.length) {
|
||
const ids = items.map(listing => listing.id).join(',');
|
||
try { historyState.publishStatus = await api('GET', `/api/publish/status?ids=${ids}`); }
|
||
catch { historyState.publishStatus = {}; }
|
||
} else {
|
||
historyState.publishStatus = {};
|
||
}
|
||
|
||
renderHistoryList(items);
|
||
}
|
||
|
||
function historyParseDate(value) {
|
||
if (!value) return new Date(0);
|
||
const normalized = /(?:Z|[+-]\d\d:\d\d)$/.test(value) ? value : `${value.replace(' ', 'T')}Z`;
|
||
const date = new Date(normalized);
|
||
return Number.isNaN(date.getTime()) ? new Date(0) : date;
|
||
}
|
||
|
||
function historyFormatDate(value, withTime = false) {
|
||
const date = historyParseDate(value);
|
||
if (!date.getTime()) return '—';
|
||
return date.toLocaleString('de-DE', withTime
|
||
? { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }
|
||
: { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
}
|
||
|
||
function historyProviderLabel(provider) {
|
||
return ({ claude: 'Claude', openai: 'OpenAI', openrouter: 'OpenRouter', local: 'Ollama' })[provider] || provider || '—';
|
||
}
|
||
|
||
function historyStatusMeta(status) {
|
||
return ({
|
||
active: { label: 'Aktiv', className: 'active' },
|
||
sold: { label: 'Verkauft', className: 'sold' },
|
||
reserved: { label: 'Reserviert', className: 'reserved' },
|
||
})[status] || { label: status || 'Aktiv', className: 'active' };
|
||
}
|
||
|
||
function historyPublishSummary(listing) {
|
||
const statuses = historyState.publishStatus[listing.id] || {};
|
||
const entries = Object.entries(statuses);
|
||
if (!entries.length) return '<span class="history-publish-empty">Nicht veröffentlicht</span>';
|
||
return entries.slice(0, 2).map(([platform, status]) => {
|
||
const meta = {
|
||
published: ['Live', 'published'],
|
||
copied: ['Kopiert', 'copied'],
|
||
pending: ['Ausstehend', 'pending'],
|
||
failed: ['Fehler', 'failed'],
|
||
}[status] || [status, 'pending'];
|
||
return `<span class="history-publish-badge ${meta[1]}"><span>${escapeHtml(platformLabel(platform))}</span>${escapeHtml(meta[0])}</span>`;
|
||
}).join('');
|
||
}
|
||
|
||
function renderHistoryList(listings) {
|
||
const container = document.getElementById('history-list');
|
||
if (!listings.length) {
|
||
container.innerHTML = `<div class="history-empty-state">
|
||
<div class="history-empty-icon"><svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="4" y="3" width="16" height="18" rx="2"/><path d="M8 8h8M8 12h6"/></svg></div>
|
||
<h3>Keine Artikel gefunden</h3>
|
||
<p>Ändere die Filter oder erstelle ein neues Listing.</p>
|
||
<button class="vd-button primary" type="button" data-empty-new-listing>Neuer Artikel</button>
|
||
</div>`;
|
||
container.querySelector('[data-empty-new-listing]')?.addEventListener('click', () => switchToTab('generator'));
|
||
updateHistoryToolbar();
|
||
applyHistoryColumns();
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = listings.map(listing => {
|
||
const checked = historyState.selected.has(listing.id);
|
||
const photo = listing.photos?.[0]
|
||
? `<img class="history-thumb" src="${escapeHtml(photoUrl(listing.photos[0]))}" alt="" loading="lazy">`
|
||
: '<div class="history-thumb history-thumb-placeholder" aria-hidden="true"><svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="m4 7 8-4 8 4-8 4Z"/><path d="m4 7 8 4 8-4v10l-8 4-8-4Z"/><path d="M12 11v10"/></svg></div>';
|
||
const status = historyStatusMeta(listing.status || 'active');
|
||
const provider = historyProviderLabel(listing.ai_provider);
|
||
const platform = platformLabel(listing.platform);
|
||
const price = listing.price !== null && listing.price !== undefined ? formatCurrency(listing.price) : '—';
|
||
const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`;
|
||
|
||
return `<div class="history-row${checked ? ' selected' : ''}" data-id="${listing.id}" role="row">
|
||
<div class="history-cell history-cell-select" role="cell"><input class="history-row-select" type="checkbox" data-id="${listing.id}" ${checked ? 'checked' : ''} aria-label="${escapeHtml(listing.title || 'Listing')} auswählen"></div>
|
||
<div class="history-cell history-cell-article" role="cell">
|
||
${photo}
|
||
<div class="history-article-copy">
|
||
<button class="history-article-title" type="button" data-open-listing="${listing.id}">${escapeHtml(listing.title || 'Ohne Titel')}</button>
|
||
<div class="history-article-sku">SKU ${escapeHtml(sku)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="history-cell history-cell-platform" data-column="platform" role="cell"><span class="history-platform-name platform-${escapeHtml(listing.platform || '')}">${escapeHtml(platform)}</span><span class="history-cell-sub">${escapeHtml((listing.language || 'de').toUpperCase())}</span></div>
|
||
<div class="history-cell history-cell-provider" data-column="provider" role="cell"><span class="history-provider-mark">${escapeHtml(provider.slice(0, 1))}</span><span><strong>${escapeHtml(provider)}</strong><small>Vision AI</small></span></div>
|
||
<div class="history-cell history-cell-price" data-column="price" role="cell"><strong>${escapeHtml(price)}</strong><span class="history-cell-sub">Festpreis</span></div>
|
||
<div class="history-cell history-cell-status" data-column="status" role="cell">
|
||
<select class="history-status-select status-${status.className}" data-id="${listing.id}" aria-label="Status ändern">
|
||
<option value="active"${listing.status === 'active' || !listing.status ? ' selected' : ''}>Aktiv</option>
|
||
<option value="sold"${listing.status === 'sold' ? ' selected' : ''}>Verkauft</option>
|
||
<option value="reserved"${listing.status === 'reserved' ? ' selected' : ''}>Reserviert</option>
|
||
</select>
|
||
<span class="history-cell-sub">Seit ${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at))}</span>
|
||
</div>
|
||
<div class="history-cell history-cell-location" data-column="location" role="cell"><strong>${escapeHtml(listing.storage_location || 'Nicht zugewiesen')}</strong><span class="history-cell-sub">${escapeHtml(listing.category || '—')}</span></div>
|
||
<div class="history-cell history-cell-published" data-column="published" role="cell"><span class="history-publish-date">${escapeHtml(historyFormatDate(listing.updated_at || listing.created_at, true))}</span><div class="history-publish-badges">${historyPublishSummary(listing)}</div></div>
|
||
<div class="history-cell history-cell-menu" role="cell">
|
||
<button class="history-row-menu-button" type="button" data-row-menu="${listing.id}" aria-label="Aktionen für ${escapeHtml(listing.title || 'Listing')}">•••</button>
|
||
<div class="history-row-menu hidden" data-row-menu-panel="${listing.id}">
|
||
<button type="button" data-row-action="open" data-id="${listing.id}">Öffnen</button>
|
||
<button type="button" data-row-action="duplicate" data-id="${listing.id}">Duplizieren</button>
|
||
<button type="button" data-row-action="delete" data-id="${listing.id}" class="danger">Löschen</button>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
container.querySelectorAll('.history-row-select').forEach(checkbox => {
|
||
checkbox.addEventListener('click', event => event.stopPropagation());
|
||
checkbox.addEventListener('change', () => {
|
||
const id = Number(checkbox.dataset.id);
|
||
if (checkbox.checked) historyState.selected.add(id);
|
||
else historyState.selected.delete(id);
|
||
checkbox.closest('.history-row')?.classList.toggle('selected', checkbox.checked);
|
||
updateHistoryToolbar();
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('[data-open-listing]').forEach(button => button.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
showDetail(Number(button.dataset.openListing));
|
||
}));
|
||
|
||
container.querySelectorAll('.history-row').forEach(row => row.addEventListener('dblclick', event => {
|
||
if (!event.target.closest('button, input, select')) showDetail(Number(row.dataset.id));
|
||
}));
|
||
|
||
container.querySelectorAll('.history-status-select').forEach(select => {
|
||
select.addEventListener('click', event => event.stopPropagation());
|
||
select.addEventListener('change', async () => {
|
||
await api('PUT', `/api/listings/${select.dataset.id}`, { status: select.value });
|
||
toast('Status aktualisiert');
|
||
loadHistory();
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('[data-row-menu]').forEach(button => button.addEventListener('click', event => {
|
||
event.stopPropagation();
|
||
const panel = container.querySelector(`[data-row-menu-panel="${button.dataset.rowMenu}"]`);
|
||
const willOpen = panel?.classList.contains('hidden');
|
||
container.querySelectorAll('.history-row-menu').forEach(menu => menu.classList.add('hidden'));
|
||
if (willOpen) panel?.classList.remove('hidden');
|
||
}));
|
||
|
||
container.querySelectorAll('[data-row-action]').forEach(button => button.addEventListener('click', async event => {
|
||
event.stopPropagation();
|
||
const id = Number(button.dataset.id);
|
||
const action = button.dataset.rowAction;
|
||
if (action === 'open') showDetail(id);
|
||
if (action === 'duplicate') {
|
||
const copy = await api('POST', `/api/listings/${id}/duplicate`);
|
||
toast(`Kopie erstellt: „${copy.title}“`);
|
||
loadHistory();
|
||
}
|
||
if (action === 'delete') {
|
||
if (!confirm('Listing in den Papierkorb verschieben?')) return;
|
||
await api('DELETE', `/api/listings/${id}`);
|
||
historyState.selected.delete(id);
|
||
toast('Listing in den Papierkorb verschoben');
|
||
await loadHistory();
|
||
updateTrashBadge();
|
||
}
|
||
}));
|
||
|
||
document.getElementById('history-select-all').checked = listings.every(listing => historyState.selected.has(listing.id));
|
||
updateHistoryToolbar();
|
||
applyHistoryColumns();
|
||
}
|
||
|
||
function updateHistoryToolbar() {
|
||
const count = historyState.selected.size;
|
||
const countLabel = document.getElementById('history-selected-count');
|
||
if (countLabel) countLabel.textContent = `${count} ausgewählt`;
|
||
['history-delete-selected', 'history-bulk-status', 'history-print-labels', 'history-publish-selected', 'history-edit-selected', 'history-price-selected', 'history-duplicate-selected']
|
||
.forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; });
|
||
const clear = document.getElementById('history-clear-selection');
|
||
clear?.classList.toggle('hidden', count === 0);
|
||
document.getElementById('history-batch-bar')?.classList.toggle('has-selection', count > 0);
|
||
const selectAll = document.getElementById('history-select-all');
|
||
if (selectAll) {
|
||
const selectedOnPage = historyState.listings.filter(listing => historyState.selected.has(listing.id)).length;
|
||
selectAll.checked = historyState.listings.length > 0 && selectedOnPage === historyState.listings.length;
|
||
selectAll.indeterminate = selectedOnPage > 0 && selectedOnPage < historyState.listings.length;
|
||
}
|
||
}
|
||
|
||
let detailRenderSequence = 0;
|
||
let detailPhotoDragIndex = -1;
|
||
|
||
function detailPlatformOptions(selected) {
|
||
return state.platforms.map(platform => `<option value="${escapeHtml(platform.id)}"${platform.id === selected ? ' selected' : ''}>${escapeHtml(platform.name)}</option>`).join('');
|
||
}
|
||
|
||
function detailProviderOptions(selected) {
|
||
return state.providers.map(provider => `<option value="${escapeHtml(provider.id)}"${provider.id === selected ? ' selected' : ''}>${escapeHtml(provider.name)}</option>`).join('');
|
||
}
|
||
|
||
function detailLanguageOptions(selected) {
|
||
const languages = [['de', 'Deutsch'], ['en', 'English'], ['fr', 'Français'], ['nl', 'Nederlands'], ['pl', 'Polski'], ['es', 'Español'], ['it', 'Italiano'], ['pt', 'Português']];
|
||
return languages.map(([value, label]) => `<option value="${value}"${value === selected ? ' selected' : ''}>${label}</option>`).join('');
|
||
}
|
||
|
||
function getDetailListingForHtml() {
|
||
if (!detailEditorState) return null;
|
||
const tags = (document.getElementById('detail-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean);
|
||
return {
|
||
platform: document.getElementById('detail-platform')?.value || detailEditorState.listing.platform,
|
||
title: document.getElementById('detail-title')?.value || '',
|
||
description: editors.detail ? editors.detail.getText().trim() : '',
|
||
description_html: getEditorHtml(editors.detail),
|
||
tags,
|
||
brand: document.getElementById('detail-brand')?.value || null,
|
||
category: detailEditorState.categoryPicker?.getValue?.() || null,
|
||
size: document.getElementById('detail-size')?.value || null,
|
||
color: document.getElementById('detail-color')?.value || null,
|
||
condition: document.getElementById('detail-condition')?.value || null,
|
||
};
|
||
}
|
||
|
||
const refreshDetailPlatformPreview = debounce(async () => {
|
||
const listing = getDetailListingForHtml();
|
||
if (!listing) return;
|
||
const sequence = ++detailRenderSequence;
|
||
setHtmlSyncStatus('detail-html-sync-status', 'Wird aktualisiert …', 'working');
|
||
try {
|
||
const data = await api('POST', '/api/html-render', { platform: listing.platform, listing });
|
||
if (sequence !== detailRenderSequence) return;
|
||
detailEditorState.platformHtml = data.html || listing.description_html;
|
||
setSandboxPreview(document.getElementById('detail-html-preview'), detailEditorState.platformHtml);
|
||
setHtmlSyncStatus('detail-html-sync-status', 'Synchron', 'ok');
|
||
} catch {
|
||
if (sequence !== detailRenderSequence) return;
|
||
setSandboxPreview(document.getElementById('detail-html-preview'), listing.description_html);
|
||
setHtmlSyncStatus('detail-html-sync-status', 'Vorschau lokal', 'warning');
|
||
}
|
||
}, 140);
|
||
|
||
function setupDetailHtmlSync() {
|
||
const editor = editors.detail;
|
||
const source = document.getElementById('detail-html-source');
|
||
if (!editor || !source) return;
|
||
|
||
editor.on('text-change', () => {
|
||
if (htmlSyncState.detailLock) return;
|
||
source.value = getEditorHtml(editor);
|
||
const count = document.getElementById('detail-description-count');
|
||
if (count) count.textContent = `${editor.getText().trim().length} Zeichen`;
|
||
refreshDetailPlatformPreview();
|
||
});
|
||
|
||
const applySource = debounce(() => {
|
||
htmlSyncState.detailLock = true;
|
||
const safe = sanitizeRichHtmlClient(source.value);
|
||
setEditorHtml(editor, safe);
|
||
htmlSyncState.detailLock = false;
|
||
source.value = safe;
|
||
const count = document.getElementById('detail-description-count');
|
||
if (count) count.textContent = `${editor.getText().trim().length} Zeichen`;
|
||
refreshDetailPlatformPreview();
|
||
}, 100);
|
||
|
||
source.addEventListener('input', () => {
|
||
setHtmlSyncStatus('detail-html-sync-status', 'Code wird übernommen …', 'working');
|
||
setSandboxPreview(document.getElementById('detail-html-preview'), sanitizeRichHtmlClient(source.value));
|
||
applySource();
|
||
});
|
||
}
|
||
|
||
function renderDetailPhotos() {
|
||
if (!detailEditorState) return;
|
||
const photos = detailEditorState.photos;
|
||
const hero = document.getElementById('detail-photo-hero');
|
||
const empty = document.getElementById('detail-photo-empty');
|
||
const strip = document.getElementById('detail-photo-strip');
|
||
const count = document.getElementById('detail-photo-count');
|
||
const activeIndex = Math.min(detailEditorState.activePhotoIndex || 0, Math.max(0, photos.length - 1));
|
||
detailEditorState.activePhotoIndex = activeIndex;
|
||
if (count) count.textContent = `${photos.length} / 10`;
|
||
if (hero && empty) {
|
||
if (photos.length) {
|
||
hero.src = photoUrl(photos[activeIndex]);
|
||
hero.alt = `Produktfoto ${activeIndex + 1}`;
|
||
hero.classList.remove('hidden');
|
||
empty.classList.add('hidden');
|
||
} else {
|
||
hero.removeAttribute('src');
|
||
hero.classList.add('hidden');
|
||
empty.classList.remove('hidden');
|
||
}
|
||
}
|
||
['detail-edit-photo', 'detail-set-cover', 'detail-remove-photo'].forEach(id => {
|
||
const button = document.getElementById(id);
|
||
if (button) button.disabled = photos.length === 0;
|
||
});
|
||
if (!strip) return;
|
||
strip.innerHTML = photos.map((photo, index) => `
|
||
<button type="button" class="detail-photo-thumb${index === activeIndex ? ' is-active' : ''}${index === 0 ? ' is-cover' : ''}" data-detail-photo-index="${index}" draggable="true" title="Foto ${index + 1}">
|
||
<img src="${escapeHtml(photoUrl(photo))}" alt="Foto ${index + 1}" loading="lazy">
|
||
${index === 0 ? '<span>Cover</span>' : ''}
|
||
</button>`).join('');
|
||
strip.querySelectorAll('[data-detail-photo-index]').forEach(button => {
|
||
const index = Number(button.dataset.detailPhotoIndex);
|
||
button.addEventListener('click', () => { detailEditorState.activePhotoIndex = index; renderDetailPhotos(); });
|
||
button.addEventListener('dragstart', event => { detailPhotoDragIndex = index; event.dataTransfer.effectAllowed = 'move'; button.classList.add('is-dragging'); });
|
||
button.addEventListener('dragend', () => { detailPhotoDragIndex = -1; button.classList.remove('is-dragging'); });
|
||
button.addEventListener('dragover', event => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; });
|
||
button.addEventListener('drop', event => {
|
||
event.preventDefault();
|
||
if (detailPhotoDragIndex < 0 || detailPhotoDragIndex === index) return;
|
||
const [moved] = detailEditorState.photos.splice(detailPhotoDragIndex, 1);
|
||
detailEditorState.photos.splice(index, 0, moved);
|
||
detailEditorState.activePhotoIndex = index;
|
||
detailPhotoDragIndex = -1;
|
||
renderDetailPhotos();
|
||
});
|
||
});
|
||
}
|
||
|
||
async function uploadDetailPhotos(files) {
|
||
if (!detailEditorState) return;
|
||
const available = Math.max(0, 10 - detailEditorState.photos.length);
|
||
const accepted = Array.from(files || []).filter(file => file.type.startsWith('image/')).slice(0, available);
|
||
if (!available) return toast('Maximal 10 Fotos pro Listing');
|
||
if (!accepted.length) return;
|
||
const platform = document.getElementById('detail-platform')?.value || detailEditorState.listing.platform || 'allgemein';
|
||
const formData = new FormData();
|
||
formData.append('platform', platform);
|
||
accepted.forEach(file => formData.append('photos', file));
|
||
try {
|
||
const response = await fetch(`/api/upload?platform=${encodeURIComponent(platform)}`, {
|
||
method: 'POST', body: formData, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin',
|
||
});
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.error || 'Upload fehlgeschlagen');
|
||
detailEditorState.photos.push(...(data.filenames || []));
|
||
detailEditorState.activePhotoIndex = Math.max(0, detailEditorState.photos.length - (data.filenames || []).length);
|
||
renderDetailPhotos();
|
||
toast(`${(data.filenames || []).length} Foto${(data.filenames || []).length === 1 ? '' : 's'} hinzugefügt`);
|
||
} catch (err) { toast('Upload fehlgeschlagen: ' + err.message); }
|
||
}
|
||
|
||
function setupDetailPhotoActions() {
|
||
const fileInput = document.getElementById('detail-photo-input');
|
||
document.getElementById('detail-add-photo')?.addEventListener('click', () => fileInput?.click());
|
||
document.getElementById('detail-mobile-photo')?.addEventListener('click', () => openMobileUpload('listing', detailEditorState?.listing?.id));
|
||
fileInput?.addEventListener('change', () => { uploadDetailPhotos(fileInput.files); fileInput.value = ''; });
|
||
document.getElementById('detail-edit-photo')?.addEventListener('click', () => openPhotoEditor(detailEditorState.activePhotoIndex, 'detail'));
|
||
document.getElementById('detail-set-cover')?.addEventListener('click', () => {
|
||
const index = detailEditorState.activePhotoIndex;
|
||
if (index <= 0 || !detailEditorState.photos[index]) return;
|
||
const [photo] = detailEditorState.photos.splice(index, 1);
|
||
detailEditorState.photos.unshift(photo);
|
||
detailEditorState.activePhotoIndex = 0;
|
||
renderDetailPhotos();
|
||
toast('Coverfoto festgelegt');
|
||
});
|
||
document.getElementById('detail-download-photos')?.addEventListener('click', () => downloadPublishPhotos(detailEditorState?.photos || []));
|
||
document.getElementById('detail-remove-photo')?.addEventListener('click', () => {
|
||
const index = detailEditorState.activePhotoIndex;
|
||
if (!detailEditorState.photos[index]) return;
|
||
detailEditorState.photos.splice(index, 1);
|
||
detailEditorState.activePhotoIndex = Math.max(0, Math.min(index, detailEditorState.photos.length - 1));
|
||
renderDetailPhotos();
|
||
});
|
||
}
|
||
|
||
async function showDetail(id) {
|
||
const numericId = Number(id);
|
||
if (!Number.isInteger(numericId) || numericId <= 0) {
|
||
toast('Ungültige Listing-ID');
|
||
return;
|
||
}
|
||
|
||
const historySection = document.getElementById('history');
|
||
if (!historySection?.classList.contains('active')) {
|
||
switchToTab('history');
|
||
await new Promise(resolve => requestAnimationFrame(resolve));
|
||
}
|
||
|
||
const l = await api('GET', `/api/listings/${numericId}`);
|
||
const tags = Array.isArray(l.tags) ? l.tags.join(', ') : (l.tags || '');
|
||
const skuVal = l.sku || `VD-${String(l.id).padStart(4, '0')}`;
|
||
detailEditorState = {
|
||
listing: l,
|
||
photos: [...(Array.isArray(l.photos) ? l.photos : [])],
|
||
activePhotoIndex: 0,
|
||
categoryPicker: null,
|
||
platformHtml: '',
|
||
aiModelPhotos: [],
|
||
aiModelGenerations: [],
|
||
};
|
||
|
||
document.getElementById('detail-content').innerHTML = `
|
||
<div class="listing-editor-shell">
|
||
<header class="listing-editor-header">
|
||
<div>
|
||
<span class="listing-editor-kicker">Artikel vollständig bearbeiten</span>
|
||
<h2>${escapeHtml(l.title || 'Unbenanntes Listing')}</h2>
|
||
<p>${escapeHtml(skuVal)} · ${escapeHtml(new Date((l.created_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE'))}</p>
|
||
</div>
|
||
<div class="listing-editor-header-actions">
|
||
<button id="detail-copy-all" class="vd-button secondary" type="button">Alles kopieren</button>
|
||
<button id="detail-save" class="vd-button primary" type="button">Änderungen speichern</button>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="listing-editor-grid">
|
||
<aside class="listing-media-editor">
|
||
<div class="listing-section-heading"><div><span>01</span><h3>Fotos & Medien</h3></div><b id="detail-photo-count">0 / 10</b></div>
|
||
<div class="detail-photo-stage">
|
||
<img id="detail-photo-hero" class="hidden" alt="Produktfoto">
|
||
<div id="detail-photo-empty" class="detail-photo-empty">Noch keine Fotos</div>
|
||
</div>
|
||
<div id="detail-photo-strip" class="detail-photo-strip"></div>
|
||
<input id="detail-photo-input" type="file" accept="image/*" multiple hidden>
|
||
<div class="detail-photo-actions">
|
||
<button id="detail-add-photo" class="vd-button compact" type="button">Fotos hinzufügen</button>
|
||
<button id="detail-mobile-photo" class="vd-button compact" type="button">Handy-Upload · QR</button>
|
||
<button id="detail-edit-photo" class="vd-button compact" type="button">Bild bearbeiten</button>
|
||
<button id="detail-set-cover" class="vd-button compact" type="button">Als Cover</button>
|
||
<button id="detail-remove-photo" class="vd-button compact danger" type="button">Entfernen</button>
|
||
<button id="detail-download-photos" class="vd-button compact" type="button">Fotos herunterladen</button>
|
||
</div>
|
||
<p class="detail-photo-help">Vorschaubilder ziehen, um die Reihenfolge zu ändern. Das erste Bild ist das Cover.</p>
|
||
<section class="ai-model-card detail-ai-model-card hidden" data-module-panel="vendoo.product-image-tools">
|
||
<div class="ai-model-card-head">
|
||
<div>
|
||
<span class="generator-panel-index">AI</span>
|
||
<div>
|
||
<h4>Produktbild Studio</h4>
|
||
<p>Optionales Modul für kontrollierte Ghost-Mannequin- und Flatlay-Varianten aus den aktuellen Listing-Fotos.</p>
|
||
</div>
|
||
</div>
|
||
<span class="generator-beta">Safe</span>
|
||
</div>
|
||
<div class="ai-model-controls">
|
||
<label>Look
|
||
<select id="detail-ai-model-preset" class="model-select">
|
||
<option value="mixed">Mix</option>
|
||
<option value="studio">Studio</option>
|
||
<option value="indoor">Indoor</option>
|
||
<option value="outdoor">Outdoor</option>
|
||
</select>
|
||
</label>
|
||
<label>Modus
|
||
<select id="detail-ai-model-mode" class="model-select">
|
||
<option value="ghost_mannequin">Ghost Mannequin</option>
|
||
<option value="flatlay" selected>Flatlay</option>
|
||
</select>
|
||
</label>
|
||
<label>Varianten
|
||
<select id="detail-ai-model-variants" class="model-select">
|
||
<option value="1" selected>1 Bild</option>
|
||
<option value="2">2 Bilder</option>
|
||
<option value="3">3 Bilder</option>
|
||
</select>
|
||
</label>
|
||
<button id="detail-ai-model-prompt-btn" class="small-btn ghost" type="button">Prompt…</button>
|
||
<button id="detail-ai-model-btn" class="small-btn" type="button">Bild erzeugen</button>
|
||
</div>
|
||
<div id="detail-ai-model-status" class="ai-model-status">Nutzt die aktuellen Listing-Fotos als Vorlage.</div>
|
||
<div id="detail-ai-model-grid" class="ai-model-grid hidden"></div>
|
||
<div class="ai-generation-history-wrap">
|
||
<div class="ai-generation-history-head"><strong>Historie</strong><span>Vorherige Generierungen schnell übernehmen</span></div>
|
||
<div id="detail-ai-model-history" class="ai-generation-history"></div>
|
||
</div>
|
||
</section>
|
||
</aside>
|
||
|
||
<main class="listing-content-editor">
|
||
<section class="listing-editor-section">
|
||
<div class="listing-section-heading"><div><span>02</span><h3>Listing-Daten</h3></div></div>
|
||
<div class="listing-form-grid three">
|
||
<label>Plattform<select id="detail-platform">${detailPlatformOptions(l.platform)}</select></label>
|
||
<label>AI-Provider<select id="detail-ai-provider">${detailProviderOptions(l.ai_provider)}</select></label>
|
||
<label>Sprache<select id="detail-language">${detailLanguageOptions(l.language || 'de')}</select></label>
|
||
<label>SKU<input id="detail-sku" value="${escapeHtml(skuVal)}"></label>
|
||
<label>Status<select id="detail-status"><option value="active"${(l.status || 'active') === 'active' ? ' selected' : ''}>Aktiv</option><option value="sold"${l.status === 'sold' ? ' selected' : ''}>Verkauft</option><option value="reserved"${l.status === 'reserved' ? ' selected' : ''}>Reserviert</option></select></label>
|
||
<label>Lagerort<input id="detail-storage-location" value="${escapeHtml(l.storage_location || '')}" placeholder="z. B. Regal A2"></label>
|
||
</div>
|
||
<label class="listing-full-field">Titel <span id="detail-title-count">${String(l.title || '').length} / 120</span><input id="detail-title" maxlength="120" value="${escapeHtml(l.title || '')}"></label>
|
||
<label class="listing-full-field">Tags / Hashtags<input id="detail-tags" value="${escapeHtml(tags)}" placeholder="Tags durch Komma trennen"></label>
|
||
</section>
|
||
|
||
<section class="listing-editor-section listing-description-workbench">
|
||
<div class="listing-section-heading"><div><span>03</span><h3>Beschreibung</h3></div><b id="detail-description-count">0 Zeichen</b></div>
|
||
<div class="detail-description-pane detail-wysiwyg-pane"><div class="detail-pane-head"><strong>WYSIWYG</strong><span>Formatierte Bearbeitung</span></div><div id="detail-description-editor"></div></div>
|
||
<div class="detail-platform-workbench">
|
||
<div class="html-view-toolbar detail-html-toolbar">
|
||
<div class="html-view-tabs" role="tablist" aria-label="Plattformdarstellung">
|
||
<button class="html-view-tab is-active" type="button" data-html-scope="detail" data-html-view="preview">Plattformvorschau</button>
|
||
<button class="html-view-tab" type="button" data-html-scope="detail" data-html-view="code">HTML-Code</button>
|
||
</div>
|
||
<span id="detail-html-sync-status">Synchron</span>
|
||
</div>
|
||
<div class="html-switch-stage detail-html-switch-stage">
|
||
<div id="detail-preview-view" class="html-switch-view is-active" data-html-panel="preview"><iframe id="detail-html-preview" class="detail-html-preview" sandbox title="Plattformvorschau"></iframe></div>
|
||
<div id="detail-code-view" class="html-switch-view" data-html-panel="code"><textarea id="detail-html-source" spellcheck="false" aria-label="HTML-Code der Beschreibung"></textarea></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="listing-editor-section">
|
||
<div class="listing-section-heading"><div><span>04</span><h3>Eigenschaften & Preis</h3></div></div>
|
||
<div class="listing-form-grid two">
|
||
<label>Marke<input id="detail-brand" value="${escapeHtml(l.brand || '')}"></label>
|
||
<label>Kategorie<div id="detail-category-picker" class="cat-picker"></div></label>
|
||
<label>Größe<input id="detail-size" value="${escapeHtml(l.size || '')}"></label>
|
||
<label>Farbe<input id="detail-color" value="${escapeHtml(l.color || '')}"></label>
|
||
<label>Zustand<input id="detail-condition" value="${escapeHtml(l.condition || '')}"></label>
|
||
<label>Preis (€)<input type="number" id="detail-price" step="0.01" min="0" value="${l.price ?? ''}"></label>
|
||
</div>
|
||
<label class="listing-full-field">Verkäufernotizen<textarea id="detail-seller-notes" rows="3">${escapeHtml(l.seller_notes || '')}</textarea></label>
|
||
</section>
|
||
</main>
|
||
|
||
<aside class="listing-context-panel">
|
||
<section class="listing-context-card">
|
||
<h3>Artikelidentität</h3>
|
||
<canvas id="detail-qr-canvas"></canvas>
|
||
<strong id="detail-sku-display">${escapeHtml(skuVal)}</strong>
|
||
</section>
|
||
<section class="listing-context-card">
|
||
<h3>HTML-Aktionen</h3>
|
||
<button id="detail-copy-editor-html" class="vd-button full" type="button">Editor-HTML kopieren</button>
|
||
<button id="detail-copy-platform-html" class="vd-button full" type="button">Plattform-HTML kopieren</button>
|
||
<button id="detail-clean-html" class="vd-button full" type="button">HTML bereinigen</button>
|
||
</section>
|
||
<section class="listing-context-card">
|
||
<h3>Ausgabe</h3>
|
||
<button id="detail-print-label" class="vd-button full" type="button">Etikett erstellen</button>
|
||
<button id="detail-print-sheet" class="vd-button full" type="button">Datenblatt erstellen</button>
|
||
</section>
|
||
<section class="listing-context-card danger-zone">
|
||
<h3>Gefahrenzone</h3>
|
||
<button id="detail-delete" class="vd-button full danger" type="button">In Papierkorb verschieben</button>
|
||
</section>
|
||
</aside>
|
||
</div>
|
||
</div>`;
|
||
|
||
applyModuleVisibility(document.getElementById('detail-content'));
|
||
|
||
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'); }
|
||
});
|
||
}
|
||
if (isUiModuleEnabled('vendoo.product-image-tools')) {
|
||
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');
|
||
renderAiModelGrid('detail', []);
|
||
loadDetailAiModelPhotos(numericId);
|
||
loadDetailAiModelGenerations(numericId);
|
||
}
|
||
renderDetailPhotos();
|
||
addQuillTooltips();
|
||
refreshDetailPlatformPreview();
|
||
|
||
if (typeof QRious !== 'undefined') {
|
||
new QRious({ element: document.getElementById('detail-qr-canvas'), value: JSON.stringify({ sku: skuVal, id: l.id }), size: 118, level: 'M' });
|
||
}
|
||
|
||
const refreshFields = ['detail-title', 'detail-tags', 'detail-brand', 'detail-size', 'detail-color', 'detail-condition'];
|
||
refreshFields.forEach(fieldId => document.getElementById(fieldId)?.addEventListener('input', refreshDetailPlatformPreview));
|
||
document.getElementById('detail-title')?.addEventListener('input', event => {
|
||
document.getElementById('detail-title-count').textContent = `${event.target.value.length} / 120`;
|
||
});
|
||
document.getElementById('detail-platform')?.addEventListener('change', event => {
|
||
const currentCategory = detailEditorState.categoryPicker?.getValue?.() || null;
|
||
detailEditorState.categoryPicker = createCategoryPicker('detail-category-picker', event.target.value, currentCategory);
|
||
refreshDetailPlatformPreview();
|
||
});
|
||
document.getElementById('detail-sku')?.addEventListener('input', event => { document.getElementById('detail-sku-display').textContent = event.target.value || 'Ohne SKU'; });
|
||
|
||
document.getElementById('detail-copy-editor-html').addEventListener('click', () => { navigator.clipboard.writeText(getEditorHtml(editors.detail)); toast('Editor-HTML kopiert'); });
|
||
document.getElementById('detail-copy-platform-html').addEventListener('click', () => { navigator.clipboard.writeText(detailEditorState.platformHtml || getEditorHtml(editors.detail)); toast('Plattform-HTML kopiert'); });
|
||
document.getElementById('detail-clean-html').addEventListener('click', () => {
|
||
const source = document.getElementById('detail-html-source');
|
||
const safe = sanitizeRichHtmlClient(source.value);
|
||
source.value = safe;
|
||
htmlSyncState.detailLock = true;
|
||
setEditorHtml(editors.detail, safe);
|
||
htmlSyncState.detailLock = false;
|
||
refreshDetailPlatformPreview();
|
||
toast('HTML bereinigt');
|
||
});
|
||
|
||
document.getElementById('detail-print-label').addEventListener('click', () => printLabels([{ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, price: parseFloat(document.getElementById('detail-price').value) || null }]));
|
||
document.getElementById('detail-print-sheet').addEventListener('click', () => printDatasheet({ ...l, photos: detailEditorState.photos, sku: document.getElementById('detail-sku').value, title: document.getElementById('detail-title').value, description: editors.detail?.getText().trim() || '', price: parseFloat(document.getElementById('detail-price').value) || null }));
|
||
|
||
document.getElementById('detail-save').addEventListener('click', async () => {
|
||
const descriptionHtml = getEditorHtml(editors.detail);
|
||
const update = {
|
||
platform: document.getElementById('detail-platform').value,
|
||
ai_provider: document.getElementById('detail-ai-provider').value,
|
||
language: document.getElementById('detail-language').value,
|
||
title: document.getElementById('detail-title').value,
|
||
description: richHtmlToText(descriptionHtml),
|
||
description_html: descriptionHtml,
|
||
tags: document.getElementById('detail-tags').value.split(',').map(tag => tag.trim()).filter(Boolean),
|
||
photos: [...detailEditorState.photos],
|
||
seller_notes: document.getElementById('detail-seller-notes').value || null,
|
||
status: document.getElementById('detail-status').value,
|
||
sku: document.getElementById('detail-sku').value || null,
|
||
brand: document.getElementById('detail-brand').value || null,
|
||
category: detailEditorState.categoryPicker?.getValue?.() || null,
|
||
size: document.getElementById('detail-size').value || null,
|
||
color: document.getElementById('detail-color').value || null,
|
||
condition: document.getElementById('detail-condition').value || null,
|
||
price: parseFloat(document.getElementById('detail-price').value) || null,
|
||
storage_location: document.getElementById('detail-storage-location').value || null,
|
||
};
|
||
try {
|
||
const saved = await api('PUT', `/api/listings/${numericId}`, { ...update, _edit_lock: listingEditLock.listingId === numericId ? listingEditLock.token : null });
|
||
detailEditorState.listing = saved;
|
||
toast('Alle Änderungen gespeichert');
|
||
loadHistory();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
});
|
||
|
||
document.getElementById('detail-copy-all').addEventListener('click', () => {
|
||
const content = `${document.getElementById('detail-title').value}\n\n${editors.detail?.getText().trim() || ''}\n\n${document.getElementById('detail-tags').value}`;
|
||
navigator.clipboard.writeText(content);
|
||
toast('Alles kopiert!');
|
||
});
|
||
|
||
document.getElementById('detail-delete').addEventListener('click', async () => {
|
||
if (!confirm('Listing in den Papierkorb verschieben?')) return;
|
||
await api('DELETE', `/api/listings/${numericId}`);
|
||
toast('In Papierkorb verschoben');
|
||
updateTrashBadge();
|
||
document.getElementById('back-to-list').click();
|
||
loadHistory();
|
||
});
|
||
|
||
setHistoryDetailMode(true);
|
||
document.getElementById('history-detail')?.scrollIntoView({ block: 'start' });
|
||
}
|
||
|
||
// --- Templates ---
|
||
function setupTemplates() {
|
||
document.getElementById('add-template-btn').addEventListener('click', () => {
|
||
state.editingTemplateId = null;
|
||
document.getElementById('template-form-title').textContent = 'Neue Vorlage';
|
||
document.getElementById('tpl-name').value = '';
|
||
document.getElementById('tpl-platform').value = '';
|
||
document.getElementById('tpl-language').value = 'de';
|
||
if (editors.tplNotes) editors.tplNotes.setText('');
|
||
document.getElementById('tpl-tags').value = '';
|
||
document.getElementById('template-form').classList.remove('hidden');
|
||
});
|
||
document.getElementById('cancel-template-btn').addEventListener('click', () => {
|
||
document.getElementById('template-form').classList.add('hidden');
|
||
});
|
||
document.getElementById('save-template-btn').addEventListener('click', async () => {
|
||
const data = {
|
||
name: document.getElementById('tpl-name').value,
|
||
platform: document.getElementById('tpl-platform').value || null,
|
||
language: document.getElementById('tpl-language').value,
|
||
seller_notes: editors.tplNotes ? editors.tplNotes.getText().trim() : '',
|
||
default_tags: document.getElementById('tpl-tags').value.split(',').map(t => t.trim()).filter(Boolean),
|
||
};
|
||
if (!data.name) { toast('Name ist Pflichtfeld'); return; }
|
||
try {
|
||
if (state.editingTemplateId) {
|
||
await api('PUT', `/api/templates/${state.editingTemplateId}`, data);
|
||
} else {
|
||
await api('POST', '/api/templates', data);
|
||
}
|
||
state.templates = await api('GET', '/api/templates');
|
||
renderTemplateList();
|
||
updateTemplateDropdown();
|
||
document.getElementById('template-form').classList.add('hidden');
|
||
toast('Vorlage gespeichert!');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
});
|
||
}
|
||
|
||
function renderTemplateList() {
|
||
const c = document.getElementById('template-list');
|
||
if (!state.templates.length) { c.innerHTML = '<div class="empty-state">Keine Vorlagen vorhanden</div>'; return; }
|
||
c.innerHTML = state.templates.map(t => {
|
||
const pName = state.platforms.find(p => p.id === t.platform)?.name || 'Alle Plattformen';
|
||
return `<div class="template-item">
|
||
<div class="tpl-info">
|
||
<div class="tpl-name">${escapeHtml(t.name)}</div>
|
||
<div class="tpl-meta">${pName} · ${t.language.toUpperCase()}</div>
|
||
</div>
|
||
<div class="tpl-actions">
|
||
<button class="small-btn tpl-edit" data-id="${t.id}">Bearbeiten</button>
|
||
<button class="small-btn tpl-delete" data-id="${t.id}">Löschen</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
c.querySelectorAll('.tpl-edit').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const t = state.templates.find(x => x.id === parseInt(btn.dataset.id));
|
||
if (!t) return;
|
||
state.editingTemplateId = t.id;
|
||
document.getElementById('template-form-title').textContent = 'Vorlage bearbeiten';
|
||
document.getElementById('tpl-name').value = t.name;
|
||
document.getElementById('tpl-platform').value = t.platform || '';
|
||
document.getElementById('tpl-language').value = t.language || 'de';
|
||
if (editors.tplNotes) editors.tplNotes.setText(t.seller_notes || '');
|
||
document.getElementById('tpl-tags').value = (t.default_tags || []).join(', ');
|
||
document.getElementById('template-form').classList.remove('hidden');
|
||
});
|
||
});
|
||
c.querySelectorAll('.tpl-delete').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Vorlage löschen?')) return;
|
||
await api('DELETE', `/api/templates/${btn.dataset.id}`);
|
||
state.templates = await api('GET', '/api/templates');
|
||
renderTemplateList();
|
||
updateTemplateDropdown();
|
||
toast('Vorlage gelöscht');
|
||
});
|
||
});
|
||
}
|
||
|
||
function updateTemplateDropdown() {
|
||
const sel = document.getElementById('template-select');
|
||
sel.innerHTML = '<option value="">Keine Vorlage</option>' +
|
||
state.templates.map(t => `<option value="${t.id}">${escapeHtml(t.name)}</option>`).join('');
|
||
}
|
||
|
||
function populateTemplateSelects(platforms) {
|
||
const sel = document.getElementById('tpl-platform');
|
||
sel.innerHTML = '<option value="">Alle</option>' +
|
||
platforms.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||
}
|
||
|
||
// --- Inventory / Storage ---
|
||
async function loadInventory() {
|
||
const locations = await api('GET', '/api/inventory/locations');
|
||
const unassigned = await api('GET', '/api/inventory/unassigned');
|
||
|
||
const statsEl = document.getElementById('inv-stats');
|
||
const totalItems = locations.reduce((s, l) => s + l.count, 0) + unassigned.length;
|
||
const totalActive = locations.reduce((s, l) => s + l.active, 0) + unassigned.filter(x => x.status === 'active').length;
|
||
const totalValue = locations.reduce((s, l) => s + l.total_value, 0) + unassigned.filter(x => x.status === 'active').reduce((s, x) => s + (x.price || 0), 0);
|
||
statsEl.innerHTML = `
|
||
<div class="inv-stat-card"><div class="inv-stat-value">${totalItems}</div><div class="inv-stat-label">Artikel gesamt</div></div>
|
||
<div class="inv-stat-card"><div class="inv-stat-value">${locations.length}</div><div class="inv-stat-label">Lagerorte</div></div>
|
||
<div class="inv-stat-card"><div class="inv-stat-value">${totalActive}</div><div class="inv-stat-label">Aktive Artikel</div></div>
|
||
<div class="inv-stat-card"><div class="inv-stat-value">${totalValue.toFixed(0)} €</div><div class="inv-stat-label">Lagerwert</div></div>`;
|
||
|
||
const locsEl = document.getElementById('inv-locations');
|
||
let html = '';
|
||
for (const loc of locations) {
|
||
html += `<div class="inv-location-card" data-location="${escapeHtml(loc.storage_location)}">
|
||
<div class="inv-loc-header">
|
||
<div class="inv-loc-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg></div>
|
||
<div class="inv-loc-name">${escapeHtml(loc.storage_location)}</div>
|
||
</div>
|
||
<div class="inv-loc-stats">
|
||
<span class="inv-loc-count">${loc.count} Artikel</span>
|
||
<span class="inv-loc-active">${loc.active} aktiv</span>
|
||
${loc.sold ? `<span class="inv-loc-sold">${loc.sold} verkauft</span>` : ''}
|
||
${loc.reserved ? `<span class="inv-loc-reserved">${loc.reserved} reserviert</span>` : ''}
|
||
</div>
|
||
<div class="inv-loc-value">${loc.total_value.toFixed(0)} €</div>
|
||
</div>`;
|
||
}
|
||
if (unassigned.length) {
|
||
html += `<div class="inv-location-card inv-unassigned" data-location="">
|
||
<div class="inv-loc-header">
|
||
<div class="inv-loc-icon"><svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg></div>
|
||
<div class="inv-loc-name">Ohne Lagerort</div>
|
||
</div>
|
||
<div class="inv-loc-stats"><span class="inv-loc-count">${unassigned.length} Artikel</span></div>
|
||
</div>`;
|
||
}
|
||
if (!html) html = '<div class="empty-state">Noch keine Lagerorte vorhanden. Weise Artikeln einen Lagerort zu.</div>';
|
||
locsEl.innerHTML = html;
|
||
|
||
document.getElementById('inv-detail').classList.add('hidden');
|
||
locsEl.classList.remove('hidden');
|
||
|
||
locsEl.querySelectorAll('.inv-location-card').forEach(card => {
|
||
card.addEventListener('click', () => showInventoryDetail(card.dataset.location));
|
||
});
|
||
|
||
document.getElementById('inv-add-location-btn').onclick = () => {
|
||
const name = prompt('Neuer Lagerort (z.B. Regal A1, Karton 5, Schrank oben):');
|
||
if (!name?.trim()) return;
|
||
toast(`Lagerort "${name.trim()}" angelegt. Weise jetzt Artikel zu.`);
|
||
loadInventory();
|
||
};
|
||
}
|
||
|
||
async function showInventoryDetail(location) {
|
||
const items = location
|
||
? await api('GET', `/api/inventory/location/${encodeURIComponent(location)}`)
|
||
: await api('GET', '/api/inventory/unassigned');
|
||
|
||
document.getElementById('inv-locations').classList.add('hidden');
|
||
const detail = document.getElementById('inv-detail');
|
||
detail.classList.remove('hidden');
|
||
document.getElementById('inv-detail-title').textContent = location || 'Ohne Lagerort';
|
||
|
||
const list = document.getElementById('inv-detail-list');
|
||
if (!items.length) {
|
||
list.innerHTML = '<div class="empty-state">Keine Artikel an diesem Lagerort</div>';
|
||
} else {
|
||
list.innerHTML = items.map(l => {
|
||
const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform;
|
||
const statusCls = l.status === 'sold' ? 'sold' : l.status === 'reserved' ? 'reserved' : '';
|
||
return `<div class="history-item">
|
||
<input type="checkbox" class="inv-item-check" data-id="${l.id}">
|
||
<div class="listing-info">
|
||
<div class="listing-title">${l.sku ? `<span class="sku-badge">${escapeHtml(l.sku)}</span>` : ''} ${escapeHtml(l.title || 'Ohne Titel')}</div>
|
||
<div class="listing-meta">${pName} · ${l.price ? l.price.toFixed(2) + ' €' : '–'} · <span class="status-dot ${statusCls}">${l.status || 'active'}</span></div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
|
||
document.getElementById('inv-back').onclick = () => loadInventory();
|
||
|
||
document.getElementById('inv-rename-btn').onclick = async () => {
|
||
if (!location) return toast('Ohne Lagerort kann nicht umbenannt werden');
|
||
const newName = prompt('Lagerort umbenennen:', location);
|
||
if (!newName?.trim() || newName.trim() === location) return;
|
||
const ids = items.map(l => l.id);
|
||
await api('PUT', '/api/inventory/bulk-location', { ids, location: newName.trim() });
|
||
toast(`Umbenannt zu "${newName.trim()}"`);
|
||
loadInventory();
|
||
};
|
||
|
||
document.getElementById('inv-move-btn').onclick = async () => {
|
||
const checked = [...detail.querySelectorAll('.inv-item-check:checked')].map(c => parseInt(c.dataset.id));
|
||
if (!checked.length) return toast('Bitte Artikel auswählen');
|
||
const dest = prompt('Ziel-Lagerort:');
|
||
if (!dest?.trim()) return;
|
||
await api('PUT', '/api/inventory/bulk-location', { ids: checked, location: dest.trim() });
|
||
toast(`${checked.length} Artikel verschoben nach "${dest.trim()}"`);
|
||
loadInventory();
|
||
};
|
||
}
|
||
|
||
// --- Fee Calculator ---
|
||
function setupFeeCalculator() {
|
||
document.getElementById('fee-price').addEventListener('input', debounce(updateFeeCalculator, 200));
|
||
}
|
||
|
||
async function updateFeeCalculator() {
|
||
const price = parseFloat(document.getElementById('fee-price').value) || 0;
|
||
if (price <= 0) { document.getElementById('fee-results').innerHTML = ''; return; }
|
||
try {
|
||
const fees = await api('GET', `/api/fees?price=${price}`);
|
||
const c = document.getElementById('fee-results');
|
||
c.innerHTML = Object.entries(fees).map(([id, f]) => {
|
||
const name = state.platforms.find(p => p.id === id)?.name || id;
|
||
return `<div class="fee-card${Number(f.fees) === 0 ? ' is-free' : ''}">
|
||
<div>
|
||
<div class="fee-platform">${escapeHtml(name)} <span>${f.seller_type === 'privat' ? 'Privat' : 'Shop'}</span></div>
|
||
<div class="fee-breakdown">${escapeHtml(f.breakdown || '')}</div>
|
||
${f.note ? `<div class="fee-note">${escapeHtml(f.note)}</div>` : ''}
|
||
</div>
|
||
<div class="fee-amounts">
|
||
<div class="fee-net-amount">${Number(f.net || 0).toFixed(2)} €</div>
|
||
<div class="fee-deducted">${Number(f.fees) === 0 ? 'Keine Verkäufergebühr' : `-${Number(f.fees).toFixed(2)} € Gebühren`}</div>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
} catch {}
|
||
}
|
||
|
||
// --- Publish Center ---
|
||
const publishState = {
|
||
selected: new Set(),
|
||
listings: [],
|
||
allFiltered: [],
|
||
publishStatus: {},
|
||
queue: [],
|
||
scheduled: [],
|
||
workflow: null,
|
||
integrations: { ebay: null, etsy: null },
|
||
selectedId: null,
|
||
view: 'smart',
|
||
renderToken: 0,
|
||
smartCopyToken: 0,
|
||
};
|
||
|
||
function setupPublish() {
|
||
const search = document.getElementById('publish-search');
|
||
if (!search) return;
|
||
|
||
search.addEventListener('input', debounce(loadPublishList, 250));
|
||
document.getElementById('publish-filter-platform')?.addEventListener('change', loadPublishList);
|
||
document.getElementById('publish-filter-status')?.addEventListener('change', loadPublishList);
|
||
document.getElementById('publish-sort')?.addEventListener('change', () => {
|
||
sortPublishListings();
|
||
renderPublishList();
|
||
});
|
||
document.getElementById('publish-select-all')?.addEventListener('change', event => {
|
||
for (const listing of publishState.listings) {
|
||
if (event.target.checked) publishState.selected.add(listing.id);
|
||
else publishState.selected.delete(listing.id);
|
||
}
|
||
renderPublishList();
|
||
updatePublishToolbar();
|
||
});
|
||
|
||
const platformSelect = document.getElementById('publish-filter-platform');
|
||
platformSelect.innerHTML = '<option value="">Alle Plattformen</option>' +
|
||
state.platforms.map(platform => `<option value="${platform.id}">${escapeHtml(platform.name)}</option>`).join('');
|
||
|
||
document.querySelectorAll('[data-publish-view]').forEach(button => {
|
||
button.addEventListener('click', () => {
|
||
setPublishView(button.dataset.publishView);
|
||
if (button.dataset.publishView === 'smart' && selectedPublishListing()) openSmartCopyModal(selectedPublishListing());
|
||
});
|
||
});
|
||
document.querySelectorAll('[data-status-target]').forEach(button => {
|
||
button.addEventListener('click', () => setPublishView(button.dataset.statusTarget));
|
||
});
|
||
|
||
document.getElementById('publish-refresh')?.addEventListener('click', loadPublishList);
|
||
document.getElementById('publish-status-refresh')?.addEventListener('click', loadPublishList);
|
||
document.getElementById('publish-batch-copy')?.addEventListener('click', batchSmartCopy);
|
||
document.getElementById('publish-batch-etsy')?.addEventListener('click', batchEtsyPublish);
|
||
document.getElementById('publish-batch-ebay')?.addEventListener('click', batchEbayAdd);
|
||
document.getElementById('publish-add-selected-ebay')?.addEventListener('click', batchEbayAdd);
|
||
document.getElementById('publish-batch-schedule')?.addEventListener('click', openBatchSchedule);
|
||
document.getElementById('publish-download-photos')?.addEventListener('click', batchDownloadPhotos);
|
||
document.getElementById('publish-ebay-queue-btn')?.addEventListener('click', openEbayQueue);
|
||
document.getElementById('publish-schedule-overview')?.addEventListener('click', openScheduleOverview);
|
||
document.getElementById('publish-ebay-queue-process')?.addEventListener('click', processEbayQueue);
|
||
document.getElementById('publish-ebay-queue-retry')?.addEventListener('click', retryEbayQueue);
|
||
|
||
document.getElementById('schedule-modal-close')?.addEventListener('click', closePublishSchedule);
|
||
document.getElementById('schedule-cancel')?.addEventListener('click', closePublishSchedule);
|
||
document.getElementById('schedule-modal')?.addEventListener('click', event => {
|
||
if (event.target === event.currentTarget) closePublishSchedule();
|
||
});
|
||
document.querySelectorAll('[data-smart-copy-close]').forEach(button => button.addEventListener('click', closeSmartCopyModal));
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key !== 'Escape') return;
|
||
if (isSmartCopyModalOpen()) closeSmartCopyModal();
|
||
if (!document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) closeFluxPromptLab();
|
||
});
|
||
}
|
||
|
||
async function publishSafeGet(url, fallback, attempts = 1) {
|
||
let lastError = null;
|
||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||
try { return await api('GET', url); }
|
||
catch (error) {
|
||
lastError = error;
|
||
if (attempt < attempts - 1) await new Promise(resolve => setTimeout(resolve, 220 * (attempt + 1)));
|
||
}
|
||
}
|
||
if (fallback === PUBLISH_REQUIRED) throw lastError || new Error('Daten konnten nicht geladen werden');
|
||
return fallback;
|
||
}
|
||
|
||
const PUBLISH_REQUIRED = Symbol('publish-required');
|
||
|
||
async function loadPublishListLegacy() {
|
||
const search = document.getElementById('publish-search');
|
||
const listContainer = document.getElementById('publish-list');
|
||
if (!search || !listContainer) return;
|
||
const renderToken = ++publishState.renderToken;
|
||
|
||
const q = search.value.trim();
|
||
const platform = document.getElementById('publish-filter-platform')?.value || '';
|
||
const status = document.getElementById('publish-filter-status')?.value || '';
|
||
const params = new URLSearchParams();
|
||
if (q) params.set('q', q);
|
||
if (platform) params.set('platform', platform);
|
||
if (status) params.set('status', status);
|
||
|
||
const refreshButtons = [document.getElementById('publish-refresh'), document.getElementById('publish-status-refresh')];
|
||
const refreshLabels = refreshButtons.map(button => button?.textContent || '');
|
||
refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = true; button.setAttribute('aria-busy', 'true'); button.textContent = index === 0 ? 'Wird geladen …' : '…'; });
|
||
if (!publishState.listings.length) listContainer.innerHTML = '<div class="publish-loading-state"><span></span><strong>Artikel werden geladen…</strong></div>';
|
||
|
||
try {
|
||
const query = params.toString();
|
||
const [listings, queue, scheduled, workflow, ebayStatus, etsyStatus] = await Promise.all([
|
||
publishSafeGet(`/api/listings${query ? `?${query}` : ''}`, PUBLISH_REQUIRED, 3),
|
||
publishSafeGet('/api/ebay/queue', [], 2),
|
||
publishSafeGet('/api/schedule', [], 2),
|
||
publishSafeGet('/api/dashboard/workflow?limit=4', null, 2),
|
||
publishSafeGet('/api/ebay/status', { connected: false, hasCredentials: false }, 2),
|
||
publishSafeGet('/api/etsy/status', { connected: false, hasApiKey: false }, 2),
|
||
]);
|
||
if (renderToken !== publishState.renderToken) return;
|
||
|
||
publishState.allFiltered = Array.isArray(listings) ? listings : [];
|
||
publishState.listings = [...publishState.allFiltered];
|
||
publishState.queue = Array.isArray(queue) ? queue : [];
|
||
publishState.scheduled = Array.isArray(scheduled) ? scheduled : [];
|
||
publishState.workflow = workflow;
|
||
publishState.integrations = { ebay: ebayStatus, etsy: etsyStatus };
|
||
|
||
const ids = publishState.listings.map(listing => listing.id);
|
||
publishState.publishStatus = ids.length
|
||
? await publishSafeGet(`/api/publish/status?ids=${ids.join(',')}`, {}, 2)
|
||
: {};
|
||
if (renderToken !== publishState.renderToken) return;
|
||
|
||
sortPublishListings();
|
||
const visibleIds = new Set(publishState.listings.map(listing => listing.id));
|
||
if (!publishState.selectedId || !visibleIds.has(publishState.selectedId)) {
|
||
const selectedVisible = [...publishState.selected].find(id => visibleIds.has(id));
|
||
publishState.selectedId = selectedVisible || publishState.listings[0]?.id || null;
|
||
}
|
||
|
||
renderPublishList();
|
||
renderPublishWorkspace();
|
||
renderPublishStatusRail();
|
||
updatePublishToolbar();
|
||
} catch (error) {
|
||
if (renderToken !== publishState.renderToken) return;
|
||
toast(`Publish-Center konnte nicht geladen werden: ${error.message}`);
|
||
listContainer.innerHTML = `<div class="publish-empty-state"><strong>Artikel konnten nicht geladen werden</strong><span>${escapeHtml(error.message)}</span><button type="button" class="vd-button compact" data-publish-retry>Erneut laden</button></div>`;
|
||
listContainer.querySelector('[data-publish-retry]')?.addEventListener('click', loadPublishList);
|
||
} finally {
|
||
if (renderToken === publishState.renderToken) refreshButtons.forEach((button, index) => { if (!button) return; button.disabled = false; button.removeAttribute('aria-busy'); button.textContent = refreshLabels[index]; });
|
||
}
|
||
}
|
||
|
||
function sortPublishListings() {
|
||
const sort = document.getElementById('publish-sort')?.value || 'newest';
|
||
const byDate = value => new Date(value || 0).getTime() || 0;
|
||
publishState.listings.sort((a, b) => {
|
||
if (sort === 'oldest') return byDate(a.created_at) - byDate(b.created_at);
|
||
if (sort === 'price-desc') return (Number(b.price) || 0) - (Number(a.price) || 0);
|
||
if (sort === 'price-asc') return (Number(a.price) || 0) - (Number(b.price) || 0);
|
||
if (sort === 'title') return String(a.title || '').localeCompare(String(b.title || ''), 'de');
|
||
return byDate(b.created_at) - byDate(a.created_at);
|
||
});
|
||
}
|
||
|
||
function publishLatestStatus(listing) {
|
||
const statuses = publishState.publishStatus[listing.id] || {};
|
||
const entries = Object.entries(statuses);
|
||
if (!entries.length) return { label: 'Vorbereitet', status: 'prepared', platform: listing.platform };
|
||
const priority = { failed: 0, uploading: 1, pending: 2, copied: 3, published: 4 };
|
||
entries.sort((a, b) => (priority[a[1]] ?? 9) - (priority[b[1]] ?? 9));
|
||
const [platform, status] = entries[0];
|
||
const labels = { failed: 'Fehlgeschlagen', uploading: 'In Bearbeitung', pending: 'Ausstehend', copied: 'Kopiert', published: 'Live' };
|
||
return { label: labels[status] || status, status, platform };
|
||
}
|
||
|
||
function renderPublishList() {
|
||
const container = document.getElementById('publish-list');
|
||
if (!container) return;
|
||
document.getElementById('publish-list-count').textContent = publishState.listings.length;
|
||
|
||
if (!publishState.listings.length) {
|
||
container.innerHTML = '<div class="publish-empty-state"><strong>Keine Artikel gefunden</strong><span>Ändere Suche oder Filter.</span></div>';
|
||
updatePublishToolbar();
|
||
return;
|
||
}
|
||
|
||
container.innerHTML = publishState.listings.map(listing => {
|
||
const photo = listing.photos?.[0];
|
||
const status = publishLatestStatus(listing);
|
||
const selected = listing.id === publishState.selectedId;
|
||
const checked = publishState.selected.has(listing.id);
|
||
return `<article class="publish-list-card${selected ? ' is-active' : ''}" data-publish-listing-id="${listing.id}">
|
||
<label class="publish-card-check" title="Auswählen"><input type="checkbox" data-publish-check="${listing.id}" ${checked ? 'checked' : ''}></label>
|
||
<div class="publish-card-media">${photo ? `<img src="${photoUrl(photo)}" alt="">` : '<span>Kein Bild</span>'}</div>
|
||
<div class="publish-card-copy">
|
||
<strong>${escapeHtml(listing.title || 'Ohne Titel')}</strong>
|
||
<span class="publish-card-sku">${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)}</span>
|
||
<div class="publish-card-meta"><span>${escapeHtml(platformLabel(listing.platform))}</span><b>${formatCurrency(listing.price) || 'Preis fehlt'}</b></div>
|
||
</div>
|
||
<span class="publish-card-status status-${escapeHtml(status.status)}">${escapeHtml(status.label)}</span>
|
||
</article>`;
|
||
}).join('');
|
||
|
||
container.querySelectorAll('[data-publish-listing-id]').forEach(card => {
|
||
card.addEventListener('click', event => {
|
||
if (event.target.closest('input,button,a,label')) return;
|
||
publishState.selectedId = Number(card.dataset.publishListingId);
|
||
renderPublishList();
|
||
renderPublishWorkspace();
|
||
});
|
||
});
|
||
|
||
container.querySelectorAll('[data-publish-check]').forEach(checkbox => {
|
||
checkbox.addEventListener('change', event => {
|
||
const id = Number(event.target.dataset.publishCheck);
|
||
if (event.target.checked) publishState.selected.add(id);
|
||
else publishState.selected.delete(id);
|
||
if (!publishState.selectedId) publishState.selectedId = id;
|
||
updatePublishToolbar();
|
||
});
|
||
});
|
||
}
|
||
|
||
function updatePublishToolbar() {
|
||
const count = publishState.selected.size;
|
||
const selectedLabel = document.getElementById('publish-selected-count');
|
||
if (selectedLabel) selectedLabel.textContent = `${count} ausgewählt`;
|
||
['publish-batch-copy', 'publish-batch-etsy', 'publish-batch-ebay', 'publish-batch-schedule', 'publish-download-photos', 'publish-add-selected-ebay']
|
||
.forEach(id => { const button = document.getElementById(id); if (button) button.disabled = count === 0; });
|
||
|
||
const visible = publishState.listings;
|
||
const selectedVisible = visible.filter(listing => publishState.selected.has(listing.id)).length;
|
||
const selectAll = document.getElementById('publish-select-all');
|
||
if (selectAll) {
|
||
selectAll.checked = visible.length > 0 && selectedVisible === visible.length;
|
||
selectAll.indeterminate = selectedVisible > 0 && selectedVisible < visible.length;
|
||
}
|
||
}
|
||
|
||
function setPublishViewLegacy(view) {
|
||
publishState.view = ['smart', 'direct', 'queue', 'scheduled'].includes(view) ? view : 'smart';
|
||
document.querySelectorAll('[data-publish-view]').forEach(button => {
|
||
const active = button.dataset.publishView === publishState.view;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-selected', String(active));
|
||
});
|
||
renderPublishWorkspace();
|
||
}
|
||
|
||
function selectedPublishListing() {
|
||
return publishState.listings.find(listing => listing.id === publishState.selectedId)
|
||
|| publishState.allFiltered.find(listing => listing.id === publishState.selectedId)
|
||
|| null;
|
||
}
|
||
|
||
function renderPublishWorkspaceLegacy() {
|
||
if (publishState.view === 'queue') { renderQueueWorkspace(); return; }
|
||
if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; }
|
||
|
||
const listing = selectedPublishListing();
|
||
const workspace = document.getElementById('publish-workspace');
|
||
if (!workspace) return;
|
||
if (!listing) {
|
||
workspace.innerHTML = `<div class="publish-workspace-empty"><span>↗</span><h3>Artikel auswählen</h3><p>Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.</p></div>`;
|
||
return;
|
||
}
|
||
if (publishState.view === 'direct') renderDirectPublishWorkspace(listing);
|
||
else renderSmartCopyLauncher(listing);
|
||
}
|
||
|
||
|
||
function renderSmartCopyLauncher(listing) {
|
||
const workspace = document.getElementById('publish-workspace');
|
||
const target = publishPlatformTarget(listing.platform);
|
||
const score = publishReadinessScore(listing);
|
||
const tags = Array.isArray(listing.tags) ? listing.tags : String(listing.tags || '').split(',').map(value => value.trim()).filter(Boolean);
|
||
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit')}
|
||
<div class="smart-copy-center">
|
||
<section class="smart-copy-center-main">
|
||
<div class="smart-copy-center-heading"><div><span class="publish-panel-kicker">Verkaufsdaten</span><h3>${escapeHtml(listing.title || 'Ohne Titel')}</h3></div><button type="button" class="vd-button primary" data-open-smart-copy-modal>${window.VendooIcons?.svg('external-link', { size: 17 }) || ''} Smart Copy öffnen</button></div>
|
||
<div class="smart-copy-center-description">${escapeHtml(listing.description || 'Noch keine Beschreibung vorhanden.').replace(/\n/g, '<br>')}</div>
|
||
<div class="smart-copy-center-tags">${tags.length ? tags.slice(0, 12).map(tag => `<span>${escapeHtml(tag)}</span>`).join('') : '<span>Keine Tags</span>'}</div>
|
||
<div class="smart-copy-center-meta">
|
||
<div><span>Plattform</span><strong>${escapeHtml(target.name)}</strong></div><div><span>Preis</span><strong>${escapeHtml(formatCurrency(listing.price) || 'Nicht gesetzt')}</strong></div>
|
||
<div><span>Zustand</span><strong>${escapeHtml(listing.condition || 'Nicht gesetzt')}</strong></div><div><span>Marke</span><strong>${escapeHtml(listing.brand || 'Nicht gesetzt')}</strong></div>
|
||
<div><span>Kategorie</span><strong>${escapeHtml(listing.category || 'Nicht gesetzt')}</strong></div><div><span>Größe</span><strong>${escapeHtml(listing.size || 'Nicht gesetzt')}</strong></div>
|
||
</div>
|
||
</section>
|
||
<aside class="smart-copy-center-side">
|
||
<div class="smart-copy-launcher-score"><strong>${score}%</strong><span>Artikel vollständig</span></div>
|
||
<section class="publish-photo-section"><div class="publish-field-head"><label>Fotos</label><span>${listing.photos?.length || 0}</span></div>${publishPhotoGrid(listing.photos || [], 8)}</section>
|
||
<p>Der Smart-Copy-Link öffnet das vollständige Popup mit allen Kopierfeldern, Fotos und Plattformaktionen.</p>
|
||
</aside>
|
||
</div>`;
|
||
bindPublishWorkspaceNavigation(workspace);
|
||
workspace.querySelector('[data-open-smart-copy-modal]')?.addEventListener('click', () => openSmartCopyModal(listing));
|
||
window.VendooIcons?.render(workspace);
|
||
}
|
||
|
||
function isSmartCopyModalOpen() {
|
||
return !document.getElementById('smart-copy-modal')?.classList.contains('hidden');
|
||
}
|
||
|
||
function closeSmartCopyModal() {
|
||
const modal = document.getElementById('smart-copy-modal');
|
||
modal?.classList.add('hidden');
|
||
modal?.setAttribute('aria-hidden', 'true');
|
||
document.getElementById('smart-copy-modal-content')?.replaceChildren();
|
||
if (document.getElementById('flux-prompt-lab')?.classList.contains('hidden')) document.body.classList.remove('modal-open');
|
||
}
|
||
|
||
function openSmartCopyModal(listing = selectedPublishListing()) {
|
||
if (!listing) return toast('Bitte zuerst ein Artikel auswählen.');
|
||
const modal = document.getElementById('smart-copy-modal');
|
||
modal?.classList.remove('hidden');
|
||
modal?.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
renderSmartCopyWorkspace(listing);
|
||
}
|
||
|
||
function publishWorkspaceHeader(listing, kicker, stateLabel) {
|
||
const photo = listing.photos?.[0];
|
||
return `<header class="publish-workspace-header">
|
||
<div class="publish-workspace-product">
|
||
<div class="publish-workspace-thumb">${photo ? `<img src="${photoUrl(photo)}" alt="">` : '<span>Kein Bild</span>'}</div>
|
||
<div><span class="publish-panel-kicker">${escapeHtml(kicker)}</span><h3>${escapeHtml(listing.title || 'Ohne Titel')}</h3><p>${escapeHtml(listing.sku || `VD-${String(listing.id).padStart(4, '0')}`)} · ${escapeHtml(platformLabel(listing.platform))}</p></div>
|
||
</div>
|
||
<div class="publish-workspace-header-actions">
|
||
<span class="publish-workspace-state">${escapeHtml(stateLabel)}</span>
|
||
<button class="icon-button" type="button" data-publish-previous title="Vorheriger Artikel">‹</button>
|
||
<button class="icon-button" type="button" data-publish-next title="Nächster Artikel">›</button>
|
||
</div>
|
||
</header>`;
|
||
}
|
||
|
||
function bindPublishWorkspaceNavigation(scope = document) {
|
||
const currentIndex = publishState.listings.findIndex(listing => listing.id === publishState.selectedId);
|
||
scope.querySelector('[data-publish-previous]')?.addEventListener('click', () => {
|
||
if (!publishState.listings.length) return;
|
||
const nextIndex = currentIndex <= 0 ? publishState.listings.length - 1 : currentIndex - 1;
|
||
publishState.selectedId = publishState.listings[nextIndex].id;
|
||
renderPublishList();
|
||
if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace();
|
||
});
|
||
scope.querySelector('[data-publish-next]')?.addEventListener('click', () => {
|
||
if (!publishState.listings.length) return;
|
||
const nextIndex = currentIndex >= publishState.listings.length - 1 ? 0 : currentIndex + 1;
|
||
publishState.selectedId = publishState.listings[nextIndex].id;
|
||
renderPublishList();
|
||
if (isSmartCopyModalOpen()) renderSmartCopyWorkspace(selectedPublishListing()); else renderPublishWorkspace();
|
||
});
|
||
}
|
||
|
||
function publishPlatformTarget(platform) {
|
||
return ({
|
||
vinted: { name: 'Vinted', url: 'https://www.vinted.de/items/new', direct: false },
|
||
'ebay-ka': { name: 'Kleinanzeigen', url: 'https://www.kleinanzeigen.de/p-anzeige-aufgeben.html', direct: false },
|
||
'ebay-de': { name: 'eBay.de', url: 'https://www.ebay.de/sell/create', direct: true },
|
||
etsy: { name: 'Etsy', url: 'https://www.etsy.com/your/shops/me/tools/listings/create', direct: true },
|
||
})[platform] || { name: platformLabel(platform), url: null, direct: false };
|
||
}
|
||
|
||
function publishPhotoGrid(photos, limit = 6) {
|
||
if (!photos?.length) return '<div class="publish-no-photos">Keine Fotos hinterlegt</div>';
|
||
const visible = photos.slice(0, limit);
|
||
return `<div class="publish-photo-grid">${visible.map(photo => `<img src="${photoUrl(photo)}" alt="">`).join('')}${photos.length > limit ? `<span>+${photos.length - limit} weitere</span>` : ''}</div>`;
|
||
}
|
||
|
||
async function renderSmartCopyWorkspace(listing) {
|
||
const workspace = document.getElementById('smart-copy-modal-content');
|
||
if (!workspace) return;
|
||
const token = ++publishState.smartCopyToken;
|
||
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Wird vorbereitet')}<div class="publish-workspace-loading"><span></span><strong>Daten werden vorbereitet …</strong></div>`;
|
||
bindPublishWorkspaceNavigation(workspace);
|
||
|
||
try {
|
||
const data = await api('POST', '/api/publish/smart-copy', { listing_id: listing.id });
|
||
if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return;
|
||
const fields = data.fields || {};
|
||
const target = publishPlatformTarget(listing.platform);
|
||
const tags = Array.isArray(fields.tags) ? fields.tags.join(', ') : (fields.tags || '');
|
||
|
||
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Bereit zum Kopieren')}
|
||
<div class="publish-smart-grid">
|
||
<div class="publish-editor-column">
|
||
<section class="publish-editor-section">
|
||
<div class="publish-field-head"><label>Titel</label><span>${String(fields.title || '').length} Zeichen</span></div>
|
||
<div class="publish-copy-field"><input id="publish-smart-title" value="${escapeHtml(fields.title || '')}" readonly><button type="button" data-copy-publish-field="publish-smart-title">Kopieren</button></div>
|
||
</section>
|
||
<section class="publish-editor-section">
|
||
<div class="publish-field-head"><label>Beschreibung</label><div><button type="button" data-copy-publish-description>Text kopieren</button><button type="button" data-copy-publish-html>HTML kopieren</button></div></div>
|
||
<textarea id="publish-smart-description" readonly>${escapeHtml(fields.description || '')}</textarea>
|
||
</section>
|
||
<section class="publish-editor-section">
|
||
<div class="publish-field-head"><label>Tags</label><span>${tags ? tags.split(',').filter(Boolean).length : 0}</span></div>
|
||
<div class="publish-copy-field"><input id="publish-smart-tags" value="${escapeHtml(tags)}" readonly><button type="button" data-copy-publish-field="publish-smart-tags">Kopieren</button></div>
|
||
</section>
|
||
<div class="publish-meta-form compact">
|
||
${publishReadonlyMeta('Preis', fields.price ? formatCurrency(fields.price) : 'Nicht gesetzt', 'publish-smart-price')}
|
||
${publishReadonlyMeta('Marke', fields.brand || '—', 'publish-smart-brand')}
|
||
${publishReadonlyMeta('Kategorie', fields.category || '—', 'publish-smart-category')}
|
||
${publishReadonlyMeta('Größe', fields.size || '—', 'publish-smart-size')}
|
||
${publishReadonlyMeta('Zustand', fields.condition || '—', 'publish-smart-condition')}
|
||
</div>
|
||
</div>
|
||
<aside class="publish-readiness-column">
|
||
<section class="publish-readiness-card">
|
||
<div class="publish-readiness-head"><div><span class="publish-panel-kicker">Zielplattform</span><h4>${escapeHtml(target.name)}</h4></div><span class="publish-platform-wordmark">${escapeHtml(platformLabel(listing.platform))}</span></div>
|
||
<ul>${publishReadinessItems(listing).map(item => `<li class="${item.ok ? 'is-ready' : 'is-missing'}"><span>${item.ok ? '✓' : '!'}</span>${escapeHtml(item.label)}</li>`).join('')}</ul>
|
||
<div class="publish-ready-message ${publishReadinessScore(listing) === 100 ? 'is-ready' : ''}"><strong>${publishReadinessScore(listing)}% vollständig</strong><small>${publishReadinessScore(listing) === 100 ? 'Alle Kerndaten sind vorhanden.' : 'Fehlende Angaben vor Veröffentlichung ergänzen.'}</small></div>
|
||
</section>
|
||
<section class="publish-photo-section"><div class="publish-field-head"><label>Fotos</label><span>${data.photos?.length || 0}</span></div>${publishPhotoGrid(data.photos || [])}<button type="button" class="vd-button compact full" data-download-smart-photos>Fotos speichern</button></section>
|
||
</aside>
|
||
</div>
|
||
<footer class="publish-workspace-footer">
|
||
<div><button type="button" class="vd-button" data-publish-edit-listing>Artikel bearbeiten</button><button type="button" class="vd-button" data-copy-publish-all>Alles kopieren</button></div>
|
||
<div>${target.url ? `<button type="button" class="vd-button" data-open-publish-platform>Plattform öffnen</button>` : ''}<button type="button" class="vd-button primary" data-mark-publish-live>Als veröffentlicht markieren</button></div>
|
||
</footer>`;
|
||
|
||
bindPublishWorkspaceNavigation(workspace);
|
||
bindSmartCopyActions(workspace, listing, data, target, fields, tags);
|
||
} catch (error) {
|
||
if (token !== publishState.smartCopyToken || !isSmartCopyModalOpen()) return;
|
||
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Smart Copy', 'Fehler')}<div class="publish-workspace-error"><strong>Smart Copy konnte nicht vorbereitet werden</strong><p>${escapeHtml(error.message)}</p><button class="vd-button" type="button" data-retry-smart-copy>Erneut versuchen</button></div>`;
|
||
bindPublishWorkspaceNavigation(workspace);
|
||
workspace.querySelector('[data-retry-smart-copy]')?.addEventListener('click', () => renderSmartCopyWorkspace(listing));
|
||
}
|
||
}
|
||
|
||
function publishReadonlyMeta(label, value, id) {
|
||
return `<label><span>${escapeHtml(label)}</span><div class="publish-copy-field"><input id="${id}" value="${escapeHtml(String(value))}" readonly><button type="button" data-copy-publish-field="${id}">Kopieren</button></div></label>`;
|
||
}
|
||
|
||
function bindSmartCopyActions(scope, listing, data, target, fields, tags) {
|
||
scope.querySelectorAll('[data-copy-publish-field]').forEach(button => {
|
||
button.addEventListener('click', () => copyPublishValue(document.getElementById(button.dataset.copyPublishField)?.value || '', button));
|
||
});
|
||
scope.querySelector('[data-copy-publish-description]')?.addEventListener('click', event => copyPublishValue(fields.description || '', event.currentTarget));
|
||
scope.querySelector('[data-copy-publish-html]')?.addEventListener('click', async event => {
|
||
try {
|
||
const html = await api('POST', '/api/html-wrap', { listing_id: listing.id, platform: listing.platform });
|
||
copyPublishValue(html.html || fields.description || '', event.currentTarget);
|
||
} catch (error) { toast(`HTML konnte nicht erstellt werden: ${error.message}`); }
|
||
});
|
||
scope.querySelector('[data-copy-publish-all]')?.addEventListener('click', event => copyPublishValue(`${fields.title || ''}\n\n${fields.description || ''}\n\n${tags}`, event.currentTarget));
|
||
scope.querySelector('[data-open-publish-platform]')?.addEventListener('click', () => { if (target.url) window.open(target.url, '_blank', 'noopener'); });
|
||
scope.querySelector('[data-mark-publish-live]')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget;
|
||
button.disabled = true;
|
||
try {
|
||
await api('POST', '/api/publish/mark', { listing_id: listing.id, platform: listing.platform, status: 'published' });
|
||
toast('Artikel als veröffentlicht markiert');
|
||
closeSmartCopyModal();
|
||
await loadPublishList();
|
||
} catch (error) { toast(`Fehler: ${error.message}`); }
|
||
finally { button.disabled = false; }
|
||
});
|
||
scope.querySelector('[data-download-smart-photos]')?.addEventListener('click', () => downloadPublishPhotos(data.photos || []));
|
||
scope.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); });
|
||
}
|
||
|
||
function copyPublishValue(value, button) {
|
||
navigator.clipboard.writeText(String(value || '')).then(() => {
|
||
const old = button?.textContent;
|
||
if (button) button.textContent = 'Kopiert';
|
||
toast('In die Zwischenablage kopiert');
|
||
if (button) setTimeout(() => { button.textContent = old; }, 1200);
|
||
}).catch(() => toast('Kopieren nicht möglich'));
|
||
}
|
||
|
||
function publishReadinessItems(listing) {
|
||
return [
|
||
{ label: 'Titel', ok: Boolean(listing.title?.trim()) },
|
||
{ label: 'Beschreibung', ok: Boolean(listing.description?.trim()) },
|
||
{ label: 'Preis', ok: Number(listing.price) > 0 },
|
||
{ label: 'Bilder', ok: Boolean(listing.photos?.length) },
|
||
{ label: 'Kategorie', ok: Boolean(listing.category?.trim()) },
|
||
{ label: 'Zustand', ok: Boolean(listing.condition?.trim()) },
|
||
];
|
||
}
|
||
|
||
function publishReadinessScore(listing) {
|
||
const items = publishReadinessItems(listing);
|
||
return Math.round((items.filter(item => item.ok).length / items.length) * 100);
|
||
}
|
||
|
||
function renderDirectPublishWorkspace(listing) {
|
||
const workspace = document.getElementById('publish-workspace');
|
||
const target = publishPlatformTarget(listing.platform);
|
||
const connected = listing.platform === 'ebay-de'
|
||
? Boolean(publishState.integrations.ebay?.connected)
|
||
: listing.platform === 'etsy'
|
||
? Boolean(publishState.integrations.etsy?.connected)
|
||
: false;
|
||
const tags = Array.isArray(listing.tags) ? listing.tags.join(', ') : (listing.tags || '');
|
||
|
||
workspace.innerHTML = `${publishWorkspaceHeader(listing, 'Direkt veröffentlichen', target.direct ? (connected ? 'Verbindung bereit' : 'Nicht verbunden') : 'Smart Copy erforderlich')}
|
||
<div class="publish-direct-grid">
|
||
<form id="publish-direct-form" class="publish-editor-column" autocomplete="off">
|
||
<section class="publish-editor-section"><div class="publish-field-head"><label for="publish-direct-title">Titel</label><span id="publish-direct-title-count">${String(listing.title || '').length}/80</span></div><input id="publish-direct-title" value="${escapeHtml(listing.title || '')}" maxlength="80"></section>
|
||
<section class="publish-editor-section"><div class="publish-field-head"><label for="publish-direct-description">Beschreibung</label><span id="publish-direct-description-count">${String(listing.description || '').length}/5000</span></div><textarea id="publish-direct-description" maxlength="5000">${escapeHtml(listing.description || '')}</textarea></section>
|
||
<section class="publish-editor-section"><div class="publish-field-head"><label for="publish-direct-tags">Tags</label><span>${Array.isArray(listing.tags) ? listing.tags.length : 0}</span></div><input id="publish-direct-tags" value="${escapeHtml(tags)}"></section>
|
||
<div class="publish-meta-form">
|
||
<label><span>Preis</span><input id="publish-direct-price" type="number" min="0" step="0.01" value="${listing.price || ''}"></label>
|
||
<label><span>Zustand</span><input id="publish-direct-condition" value="${escapeHtml(listing.condition || '')}"></label>
|
||
<label><span>Marke</span><input id="publish-direct-brand" value="${escapeHtml(listing.brand || '')}"></label>
|
||
<label><span>Modell / Größe</span><input id="publish-direct-size" value="${escapeHtml(listing.size || '')}"></label>
|
||
<label><span>Farbe</span><input id="publish-direct-color" value="${escapeHtml(listing.color || '')}"></label>
|
||
<label><span>Kategorie</span><input id="publish-direct-category" value="${escapeHtml(listing.category || '')}"></label>
|
||
</div>
|
||
</form>
|
||
<aside class="publish-readiness-column">
|
||
<section class="publish-readiness-card">
|
||
<div class="publish-readiness-head"><div><span class="publish-panel-kicker">Plattform</span><h4>${escapeHtml(target.name)}</h4></div><span class="integration-dot ${connected ? 'is-connected' : ''}"></span></div>
|
||
${target.direct ? `<p class="publish-integration-message">${connected ? 'Die API-Verbindung ist aktiv.' : 'Verbinde das Konto zuerst in den Einstellungen.'}</p>` : '<p class="publish-integration-message">Diese Plattform unterstützt derzeit Smart Copy über Vendoo.</p>'}
|
||
<ul>${publishReadinessItems(listing).map(item => `<li class="${item.ok ? 'is-ready' : 'is-missing'}"><span>${item.ok ? '✓' : '!'}</span>${escapeHtml(item.label)}</li>`).join('')}</ul>
|
||
<div class="publish-ready-message ${publishReadinessScore(listing) === 100 ? 'is-ready' : ''}"><strong>${publishReadinessScore(listing)}% vollständig</strong><small>Änderungen vor dem Publishing speichern.</small></div>
|
||
</section>
|
||
<section class="publish-photo-section"><div class="publish-field-head"><label>Fotos</label><span>${listing.photos?.length || 0}</span></div>${publishPhotoGrid(listing.photos || [])}</section>
|
||
</aside>
|
||
</div>
|
||
<footer class="publish-workspace-footer">
|
||
<div><button type="button" class="vd-button" data-publish-edit-listing>Detailansicht</button><button type="button" class="vd-button" data-publish-save-direct>Änderungen speichern</button></div>
|
||
<div>${listing.platform === 'ebay-de' ? '<button type="button" class="vd-button" data-add-current-ebay>Zur Queue</button>' : ''}<button type="button" class="vd-button primary" data-publish-direct>${target.direct ? 'Jetzt veröffentlichen' : 'Smart Copy öffnen'}</button></div>
|
||
</footer>`;
|
||
|
||
bindPublishWorkspaceNavigation(workspace);
|
||
const title = document.getElementById('publish-direct-title');
|
||
const description = document.getElementById('publish-direct-description');
|
||
title?.addEventListener('input', () => { document.getElementById('publish-direct-title-count').textContent = `${title.value.length}/80`; });
|
||
description?.addEventListener('input', () => { document.getElementById('publish-direct-description-count').textContent = `${description.value.length}/5000`; });
|
||
workspace.querySelector('[data-publish-edit-listing]')?.addEventListener('click', () => { closeSmartCopyModal(); switchToTab('history'); showDetail(listing.id); });
|
||
workspace.querySelector('[data-publish-save-direct]')?.addEventListener('click', () => saveDirectPublishDraft(listing));
|
||
workspace.querySelector('[data-add-current-ebay]')?.addEventListener('click', async () => {
|
||
publishState.selected.add(listing.id); updatePublishToolbar(); await batchEbayAdd();
|
||
});
|
||
workspace.querySelector('[data-publish-direct]')?.addEventListener('click', async event => {
|
||
if (!target.direct) { setPublishView('smart'); openSmartCopyModal(listing); return; }
|
||
if (!connected) { toast(`${target.name} ist nicht verbunden`); switchToTab('settings'); return; }
|
||
const saved = await saveDirectPublishDraft(listing, false);
|
||
if (!saved) return;
|
||
const button = event.currentTarget;
|
||
button.disabled = true; button.textContent = 'Wird eingereiht …';
|
||
try {
|
||
if (listing.platform === 'etsy') await api('POST', '/api/publish/etsy', { listing_id: listing.id });
|
||
else await api('POST', `/api/ebay/publish/${listing.id}`);
|
||
toast(`Auftrag für ${target.name} wurde sicher eingereiht`);
|
||
notify('Vendoo Publish', `${listing.title || listing.sku} wurde zur Publishing-Queue hinzugefügt.`);
|
||
await loadPublishList();
|
||
} catch (error) { toast(`Veröffentlichung fehlgeschlagen: ${error.message}`); }
|
||
finally { button.disabled = false; button.textContent = 'Jetzt veröffentlichen'; }
|
||
});
|
||
}
|
||
|
||
async function saveDirectPublishDraft(listing, showSuccess = true) {
|
||
const payload = {
|
||
title: document.getElementById('publish-direct-title')?.value.trim() || '',
|
||
description: document.getElementById('publish-direct-description')?.value.trim() || '',
|
||
tags: (document.getElementById('publish-direct-tags')?.value || '').split(',').map(tag => tag.trim()).filter(Boolean),
|
||
price: Number.parseFloat(document.getElementById('publish-direct-price')?.value) || null,
|
||
condition: document.getElementById('publish-direct-condition')?.value.trim() || null,
|
||
brand: document.getElementById('publish-direct-brand')?.value.trim() || null,
|
||
size: document.getElementById('publish-direct-size')?.value.trim() || null,
|
||
color: document.getElementById('publish-direct-color')?.value.trim() || null,
|
||
category: document.getElementById('publish-direct-category')?.value.trim() || null,
|
||
};
|
||
try {
|
||
const updated = await api('PUT', `/api/listings/${listing.id}`, payload);
|
||
const replace = items => items.map(item => item.id === updated.id ? updated : item);
|
||
publishState.listings = replace(publishState.listings);
|
||
publishState.allFiltered = replace(publishState.allFiltered);
|
||
if (showSuccess) toast('Artikel gespeichert');
|
||
return updated;
|
||
} catch (error) {
|
||
toast(`Speichern fehlgeschlagen: ${error.message}`);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function renderQueueWorkspaceLegacy() {
|
||
const workspace = document.getElementById('publish-workspace');
|
||
const queue = publishState.queue;
|
||
const counts = publishQueueCounts();
|
||
workspace.innerHTML = `<header class="publish-operation-header"><div><span class="publish-panel-kicker">eBay Automatisierung</span><h3>eBay Queue</h3><p>${counts.pending} ausstehend · ${counts.processing} in Bearbeitung · ${counts.failed} fehlgeschlagen</p></div><div><button class="vd-button" type="button" data-queue-clear>Queue leeren</button><button class="vd-button" type="button" data-queue-retry>Fehler wiederholen</button><button class="vd-button primary" type="button" data-queue-process>Alle verarbeiten</button></div></header>
|
||
<div class="publish-operation-list">${queue.length ? queue.map(item => publishQueueRow(item)).join('') : '<div class="publish-workspace-empty compact"><span>✓</span><h3>Queue ist leer</h3><p>Wähle links Artikel aus und füge sie zur eBay Queue hinzu.</p></div>'}</div>
|
||
<footer class="publish-operation-footer"><button class="vd-button" type="button" data-queue-add-selected ${publishState.selected.size ? '' : 'disabled'}>Ausgewählte Artikel hinzufügen</button><span>eBay-Verbindung: <strong>${publishState.integrations.ebay?.connected ? 'Verbunden' : 'Nicht verbunden'}</strong></span></footer>`;
|
||
|
||
workspace.querySelector('[data-queue-process]')?.addEventListener('click', processEbayQueue);
|
||
workspace.querySelector('[data-queue-retry]')?.addEventListener('click', retryEbayQueue);
|
||
workspace.querySelector('[data-queue-clear]')?.addEventListener('click', clearEbayQueueUI);
|
||
workspace.querySelector('[data-queue-add-selected]')?.addEventListener('click', batchEbayAdd);
|
||
workspace.querySelectorAll('[data-queue-remove]').forEach(button => button.addEventListener('click', async () => {
|
||
try { await api('DELETE', `/api/ebay/queue/${button.dataset.queueRemove}`); await loadPublishList(); }
|
||
catch (error) { toast(`Fehler: ${error.message}`); }
|
||
}));
|
||
}
|
||
|
||
function publishQueueRow(item) {
|
||
const labels = { pending: 'Ausstehend', uploading: 'In Bearbeitung', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' };
|
||
return `<article class="publish-operation-row">
|
||
<div class="publish-operation-product"><span class="publish-operation-mark">${escapeHtml(String(item.sku || 'VD').slice(-2))}</span><div><strong>${escapeHtml(item.title || 'Ohne Titel')}</strong><small>${escapeHtml(item.sku || '')}</small></div></div>
|
||
<div class="publish-operation-meta"><span>Status</span><b class="operation-status status-${escapeHtml(item.status)}">${escapeHtml(labels[item.status] || item.status)}</b></div>
|
||
<div class="publish-operation-meta"><span>Versuche</span><b>${Number(item.retries) || 0}</b></div>
|
||
<div class="publish-operation-error">${item.error ? `<span title="${escapeHtml(item.error)}">${escapeHtml(item.error)}</span>` : '<span>Keine Fehler</span>'}</div>
|
||
<div class="publish-operation-actions">${item.ebay_url ? `<a class="vd-button compact" href="${escapeHtml(item.ebay_url)}" target="_blank" rel="noopener">Ansehen</a>` : ''}<button class="icon-button danger" type="button" data-queue-remove="${item.id}" title="Entfernen">×</button></div>
|
||
</article>`;
|
||
}
|
||
|
||
function renderScheduledWorkspace() {
|
||
const workspace = document.getElementById('publish-workspace');
|
||
const items = publishState.scheduled;
|
||
workspace.innerHTML = `<header class="publish-operation-header"><div><span class="publish-panel-kicker">Zeitsteuerung</span><h3>Geplante Veröffentlichungen</h3><p>${items.length} Planung${items.length === 1 ? '' : 'en'} im System</p></div><button class="vd-button primary" type="button" data-schedule-selected ${publishState.selected.size || publishState.selectedId ? '' : 'disabled'}>Auswahl planen</button></header>
|
||
<div class="publish-schedule-list">${items.length ? items.map(item => publishScheduleRow(item)).join('') : '<div class="publish-workspace-empty compact"><span>◷</span><h3>Nichts geplant</h3><p>Plane ausgewählte Artikel für einen späteren Zeitpunkt.</p></div>'}</div>`;
|
||
|
||
workspace.querySelector('[data-schedule-selected]')?.addEventListener('click', openBatchSchedule);
|
||
workspace.querySelectorAll('[data-schedule-remove]').forEach(button => button.addEventListener('click', async () => {
|
||
try { await api('DELETE', `/api/schedule/${button.dataset.scheduleRemove}`); toast('Planung entfernt'); await loadPublishList(); }
|
||
catch (error) { toast(`Fehler: ${error.message}`); }
|
||
}));
|
||
}
|
||
|
||
function publishScheduleRow(item) {
|
||
const statusLabels = { scheduled: 'Geplant', publishing: 'In Bearbeitung', queued: 'An Queue übergeben', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen' };
|
||
const date = new Date(String(item.scheduled_at || '').replace(' ', 'T') + (String(item.scheduled_at || '').includes('Z') ? '' : 'Z'));
|
||
const dateText = Number.isNaN(date.getTime()) ? item.scheduled_at : date.toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' });
|
||
return `<article class="publish-schedule-row">
|
||
<div class="publish-schedule-date"><strong>${escapeHtml(dateText || '—')}</strong><span>${escapeHtml(platformLabel(item.platform))}</span></div>
|
||
<div class="publish-operation-product"><span class="publish-operation-mark">${escapeHtml(String(item.sku || 'VD').slice(-2))}</span><div><strong>${escapeHtml(item.title || 'Ohne Titel')}</strong><small>${escapeHtml(item.sku || '')}</small></div></div>
|
||
<b class="operation-status status-${escapeHtml(item.status)}">${escapeHtml(statusLabels[item.status] || item.status)}</b>
|
||
${item.error ? `<span class="publish-schedule-error">${escapeHtml(item.error)}</span>` : '<span></span>'}
|
||
${item.status === 'scheduled' ? `<button class="icon-button danger" type="button" data-schedule-remove="${item.id}" title="Planung entfernen">×</button>` : '<span></span>'}
|
||
</article>`;
|
||
}
|
||
|
||
function publishQueueCountsLegacy() {
|
||
return publishState.queue.reduce((counts, item) => {
|
||
if (item.status === 'pending') counts.pending++;
|
||
else if (item.status === 'uploading') counts.processing++;
|
||
else if (item.status === 'published') counts.published++;
|
||
else if (item.status === 'failed') counts.failed++;
|
||
return counts;
|
||
}, { pending: 0, processing: 0, published: 0, failed: 0 });
|
||
}
|
||
|
||
function renderPublishStatusRailLegacy() {
|
||
const counts = publishQueueCounts();
|
||
const workflowPublished = publishState.workflow?.stages?.published?.count || 0;
|
||
const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length;
|
||
const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; };
|
||
setText('publish-status-pending', counts.pending);
|
||
setText('publish-status-processing', counts.processing);
|
||
setText('publish-status-published', Math.max(counts.published, workflowPublished));
|
||
setText('publish-status-failed', counts.failed);
|
||
setText('publish-status-scheduled', publishState.scheduled.length);
|
||
setText('publish-direct-count', directEligible);
|
||
setText('publish-queue-count', publishState.queue.length);
|
||
setText('publish-scheduled-count', publishState.scheduled.length);
|
||
setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler warten` : 'Keine Fehler');
|
||
setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen');
|
||
|
||
const activity = publishState.workflow?.activity || [];
|
||
const container = document.getElementById('publish-activity');
|
||
if (container) {
|
||
container.innerHTML = activity.length ? activity.slice(0, 6).map(item => `<div class="publish-activity-row"><span class="publish-activity-icon type-${escapeHtml(item.type || 'pending')}">${activityIcon(item.type)}</span><div><strong>${escapeHtml(item.text || '')}</strong><small>${relativeTime(item.at)}</small></div></div>`).join('') : '<div class="publish-rail-empty">Noch keine Publish-Aktivität</div>';
|
||
}
|
||
}
|
||
|
||
async function openSmartCopy(listingId) {
|
||
publishState.selectedId = Number(listingId);
|
||
setPublishView('smart');
|
||
renderPublishList();
|
||
openSmartCopyModal(selectedPublishListing());
|
||
}
|
||
|
||
async function batchSmartCopy() {
|
||
const id = [...publishState.selected][0] || publishState.selectedId;
|
||
if (!id) return;
|
||
openSmartCopy(id);
|
||
}
|
||
|
||
async function batchEtsyPublish() {
|
||
const ids = [...publishState.selected];
|
||
const eligible = publishState.listings.filter(listing => ids.includes(listing.id) && listing.platform === 'etsy');
|
||
if (!eligible.length) { toast('Keine ausgewählten Etsy-Artikel'); return; }
|
||
let succeeded = 0;
|
||
let failed = 0;
|
||
for (const listing of eligible) {
|
||
try { await api('POST', '/api/publish/etsy', { listing_id: listing.id }); succeeded++; }
|
||
catch { failed++; }
|
||
}
|
||
toast(`Etsy: ${succeeded} Auftrag${succeeded === 1 ? '' : 'e'} eingereiht${failed ? `, ${failed} fehlgeschlagen` : ''}`);
|
||
await loadPublishList();
|
||
}
|
||
|
||
async function batchEbayAddLegacy() {
|
||
const ids = [...publishState.selected];
|
||
if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId);
|
||
if (!ids.length) return;
|
||
try {
|
||
const response = await api('POST', '/api/ebay/queue', { listing_ids: ids });
|
||
toast(`${response.added} Listing${response.added === 1 ? '' : 's'} zur eBay Queue hinzugefügt`);
|
||
publishState.view = 'queue';
|
||
await loadPublishList();
|
||
setPublishView('queue');
|
||
} catch (error) { toast(`Fehler: ${error.message}`); }
|
||
}
|
||
|
||
async function openEbayQueueLegacy() { setPublishView('queue'); }
|
||
|
||
async function processEbayQueueLegacy() {
|
||
if (!publishState.integrations.ebay?.connected) { toast('eBay ist nicht verbunden'); switchToTab('settings'); return; }
|
||
const buttons = document.querySelectorAll('[data-queue-process], #publish-ebay-queue-process');
|
||
buttons.forEach(button => { button.disabled = true; });
|
||
try {
|
||
const response = await api('POST', '/api/ebay/queue/process');
|
||
const succeeded = response.results?.filter(item => item.status === 'published').length || 0;
|
||
const failed = response.results?.filter(item => item.status === 'failed').length || 0;
|
||
toast(`${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`);
|
||
notify('Vendoo — eBay Queue', `${succeeded} veröffentlicht${failed ? `, ${failed} fehlgeschlagen` : ''}`);
|
||
await loadPublishList();
|
||
setPublishView('queue');
|
||
} catch (error) { toast(`Queue-Fehler: ${error.message}`); }
|
||
finally { buttons.forEach(button => { button.disabled = false; }); }
|
||
}
|
||
|
||
async function retryEbayQueueLegacy() {
|
||
try {
|
||
const response = await api('POST', '/api/ebay/queue/retry');
|
||
toast(`${response.retried} Einträge erneut eingeplant`);
|
||
await loadPublishList();
|
||
setPublishView('queue');
|
||
} catch (error) { toast(`Fehler: ${error.message}`); }
|
||
}
|
||
|
||
async function clearEbayQueueUI() {
|
||
if (!confirm('eBay Queue wirklich vollständig leeren?')) return;
|
||
try { await api('DELETE', '/api/ebay/queue'); toast('eBay Queue geleert'); await loadPublishList(); setPublishView('queue'); }
|
||
catch (error) { toast(`Fehler: ${error.message}`); }
|
||
}
|
||
|
||
let scheduleListingIds = [];
|
||
|
||
function openBatchSchedule() {
|
||
scheduleListingIds = [...publishState.selected];
|
||
if (!scheduleListingIds.length && publishState.selectedId) scheduleListingIds = [publishState.selectedId];
|
||
if (!scheduleListingIds.length) { toast('Bitte mindestens ein Artikel auswählen'); return; }
|
||
openPublishScheduleDialog(scheduleListingIds);
|
||
}
|
||
|
||
function openSingleSchedule(listingId) {
|
||
scheduleListingIds = [Number(listingId)];
|
||
openPublishScheduleDialog(scheduleListingIds);
|
||
}
|
||
|
||
function openPublishScheduleDialog(ids) {
|
||
const now = new Date(Date.now() + 30 * 60 * 1000);
|
||
const local = new Date(now.getTime() - now.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||
const input = document.getElementById('schedule-datetime');
|
||
input.value = local;
|
||
input.min = new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||
document.getElementById('schedule-modal-title').textContent = ids.length === 1 ? 'Veröffentlichung planen' : `${ids.length} Veröffentlichungen planen`;
|
||
const selected = publishState.listings.filter(listing => ids.includes(listing.id));
|
||
document.getElementById('schedule-listing-summary').innerHTML = selected.slice(0, 4).map(listing => `<span>${escapeHtml(listing.sku || `VD-${listing.id}`)} · ${escapeHtml(listing.title || 'Ohne Titel')}</span>`).join('') + (selected.length > 4 ? `<b>+${selected.length - 4} weitere</b>` : '');
|
||
document.getElementById('schedule-modal').classList.remove('hidden');
|
||
|
||
document.getElementById('schedule-confirm').onclick = async () => {
|
||
const dateTime = input.value;
|
||
const platform = document.getElementById('schedule-platform').value;
|
||
if (!dateTime) { toast('Bitte Datum und Uhrzeit auswählen'); return; }
|
||
const utc = new Date(dateTime).toISOString().replace('T', ' ').slice(0, 19);
|
||
let succeeded = 0;
|
||
let failed = 0;
|
||
for (const id of ids) {
|
||
try { await api('POST', '/api/schedule', { listing_id: id, platform, scheduled_at: utc }); succeeded++; }
|
||
catch { failed++; }
|
||
}
|
||
toast(`${succeeded} Veröffentlichung${succeeded === 1 ? '' : 'en'} geplant${failed ? `, ${failed} fehlgeschlagen` : ''}`);
|
||
closePublishSchedule();
|
||
publishState.view = 'scheduled';
|
||
await loadPublishList();
|
||
setPublishView('scheduled');
|
||
};
|
||
}
|
||
|
||
function closePublishSchedule() {
|
||
document.getElementById('schedule-modal')?.classList.add('hidden');
|
||
}
|
||
|
||
async function openScheduleOverview() { setPublishView('scheduled'); }
|
||
|
||
async function batchDownloadPhotos() {
|
||
const ids = [...publishState.selected];
|
||
if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId);
|
||
if (!ids.length) return;
|
||
try {
|
||
const data = await api('POST', '/api/publish/batch-photos', { listing_ids: ids });
|
||
await downloadPublishPhotos(data.photos || []);
|
||
} catch (error) { toast(`Fehler: ${error.message}`); }
|
||
}
|
||
|
||
async function downloadPublishPhotos(photos) {
|
||
const unique = [...new Set((photos || []).filter(Boolean))];
|
||
if (!unique.length) { toast('Keine Fotos vorhanden'); return; }
|
||
if (unique.length === 1) {
|
||
const photo = unique[0];
|
||
const link = document.createElement('a');
|
||
link.href = photoUrl(photo);
|
||
link.download = String(photo).split('/').pop() || 'vendoo-foto.jpg';
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
toast('Foto wird gespeichert');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/photos/archive', {
|
||
method: 'POST',
|
||
credentials: 'same-origin',
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||
body: JSON.stringify({ photos: unique }),
|
||
});
|
||
if (!response.ok) {
|
||
const error = await response.json().catch(() => ({ error: response.statusText }));
|
||
throw new Error(error.error || 'ZIP konnte nicht erstellt werden');
|
||
}
|
||
const blob = await response.blob();
|
||
const url = URL.createObjectURL(blob);
|
||
const link = document.createElement('a');
|
||
link.href = url;
|
||
link.download = `vendoo-fotos-${new Date().toISOString().slice(0, 10)}.zip`;
|
||
document.body.appendChild(link);
|
||
link.click();
|
||
link.remove();
|
||
setTimeout(() => URL.revokeObjectURL(url), 1500);
|
||
toast(`${unique.length} Fotos wurden als ZIP gepackt`);
|
||
} catch (error) {
|
||
toast(`Foto-ZIP fehlgeschlagen: ${error.message}`);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
// --- Central image gallery / media library ---
|
||
const MEDIA_SOURCE_LABELS = { flux: 'FLUX', edited: 'Bearbeitet', batch: 'Produktion', ai: 'AI-Bild', listing: 'Artikel' };
|
||
|
||
function formatMediaFileSize(bytes) {
|
||
const size = Number(bytes || 0);
|
||
if (!size) return 'Größe unbekannt';
|
||
if (size < 1024 * 1024) return `${Math.max(1, Math.round(size / 1024))} KB`;
|
||
return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
|
||
}
|
||
|
||
function renderMediaSummary() {
|
||
const summary = state.mediaSummary || { total: 0, sources: {} };
|
||
const sources = summary.sources || {};
|
||
const values = { total: summary.total || 0, flux: sources.flux || 0, edited: sources.edited || 0, batch: sources.batch || 0, ai: sources.ai || 0, listing: sources.listing || 0 };
|
||
for (const [key, value] of Object.entries(values)) document.getElementById(`media-count-${key}`)?.replaceChildren(document.createTextNode(String(value)));
|
||
const current = document.getElementById('media-source')?.value || 'all';
|
||
document.querySelectorAll('[data-media-source]').forEach(button => button.classList.toggle('active', button.dataset.mediaSource === current));
|
||
}
|
||
|
||
function updateMediaToolbar() {
|
||
const count = state.mediaSelected.size;
|
||
document.getElementById('media-selected-count')?.replaceChildren(document.createTextNode(`${count} ausgewählt`));
|
||
const download = document.getElementById('media-download-selected');
|
||
const remove = document.getElementById('media-delete-selected');
|
||
const factory = document.getElementById('media-send-to-factory');
|
||
if (download) download.disabled = count === 0;
|
||
if (remove) remove.disabled = count === 0;
|
||
if (factory) factory.disabled = count === 0;
|
||
const selectPage = document.getElementById('media-select-page');
|
||
if (selectPage) {
|
||
const selectedOnPage = state.mediaItems.filter(item => state.mediaSelected.has(item.image_path)).length;
|
||
selectPage.checked = state.mediaItems.length > 0 && selectedOnPage === state.mediaItems.length;
|
||
selectPage.indeterminate = selectedOnPage > 0 && selectedOnPage < state.mediaItems.length;
|
||
}
|
||
}
|
||
|
||
function renderMediaPagination() {
|
||
const pagination = state.mediaPagination || {};
|
||
const page = Math.max(1, Number(pagination.page || 1));
|
||
const pages = Math.max(1, Number(pagination.total_pages || 1));
|
||
const total = Math.max(0, Number(pagination.total || 0));
|
||
const pageSize = Math.max(1, Number(state.mediaPageSize || 24));
|
||
const first = total ? ((page - 1) * pageSize) + 1 : 0;
|
||
const last = total ? Math.min(total, first + state.mediaItems.length - 1) : 0;
|
||
document.querySelectorAll('[data-media-pagination]').forEach(paginationRoot => {
|
||
paginationRoot.querySelector('[data-media-page-range]')?.replaceChildren(document.createTextNode(total ? `${first}–${last} von ${total} Bildern` : '0 Bilder'));
|
||
paginationRoot.querySelector('[data-media-page-info]')?.replaceChildren(document.createTextNode(`von ${pages}`));
|
||
const select = paginationRoot.querySelector('[data-media-page-jump]');
|
||
if (select) {
|
||
const signature = `${pages}:${page}`;
|
||
if (select.dataset.signature !== signature) {
|
||
select.innerHTML = Array.from({ length: pages }, (_, index) => `<option value="${index + 1}"${index + 1 === page ? ' selected' : ''}>${index + 1}</option>`).join('');
|
||
select.dataset.signature = signature;
|
||
} else select.value = String(page);
|
||
}
|
||
const prev = paginationRoot.querySelector('[data-media-page-action="prev"]');
|
||
const next = paginationRoot.querySelector('[data-media-page-action="next"]');
|
||
if (prev) prev.disabled = page <= 1;
|
||
if (next) next.disabled = page >= pages;
|
||
});
|
||
}
|
||
|
||
function mediaSourceBadges(item) {
|
||
return (item.sources || [item.source]).map(source => `<span class="media-source-chip is-${escapeHtml(source)}">${escapeHtml(MEDIA_SOURCE_LABELS[source] || source)}</span>`).join('');
|
||
}
|
||
|
||
function mediaActionIcon(name, size = 18) {
|
||
const map = { open: 'zoom-in', edit: 'pencil', use: 'plus', download: 'download', delete: 'trash-2' };
|
||
return window.VendooIcons?.svg(map[name] || name, { size, className: 'media-action-icon' }) || '';
|
||
}
|
||
|
||
function renderMediaLibrary() {
|
||
const container = document.getElementById('media-grid');
|
||
if (!container) return;
|
||
container.classList.toggle('is-list', state.mediaView === 'list');
|
||
if (!state.mediaItems.length) {
|
||
container.innerHTML = '<div class="media-empty"><strong>Keine Bilder gefunden</strong><span>Filter anpassen oder im FLUX Studio ein neues Bild erzeugen.</span><button class="vd-button primary" type="button" data-tab-target="flux-studio">FLUX Studio öffnen</button></div>';
|
||
container.querySelector('[data-tab-target]')?.addEventListener('click', () => switchToTab('flux-studio'));
|
||
updateMediaToolbar();
|
||
return;
|
||
}
|
||
container.innerHTML = state.mediaItems.map(item => {
|
||
const selected = state.mediaSelected.has(item.image_path);
|
||
const title = item.title || item.prompt || item.image_path.split('/').pop();
|
||
const dimensions = item.width && item.height ? `${item.width} × ${item.height}` : 'Abmessungen unbekannt';
|
||
const canFavorite = (item.sources || []).includes('flux');
|
||
return `<article class="media-card${selected ? ' is-selected' : ''}" data-media-path="${escapeHtml(item.image_path)}">
|
||
<div class="media-card-image">
|
||
<button type="button" class="media-card-open" data-media-preview="${escapeHtml(item.image_path)}" aria-label="${escapeHtml(title)} in großer Vorschau öffnen">
|
||
<img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="${escapeHtml(title)}" loading="lazy">
|
||
<span class="media-open-indicator" aria-hidden="true">${mediaActionIcon('open')}</span>
|
||
</button>
|
||
<label class="media-card-select" title="Bild auswählen"><input type="checkbox" data-media-select="${escapeHtml(item.image_path)}" ${selected ? 'checked' : ''} aria-label="Bild auswählen"><span aria-hidden="true">${window.VendooIcons?.svg('check', { size: 13 }) || ''}</span></label>
|
||
${canFavorite ? `<button type="button" class="media-card-favorite-toggle${item.favorite ? ' is-active' : ''}" data-media-favorite="${escapeHtml(item.image_path)}" aria-label="${item.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}" title="${item.favorite ? 'Favorit entfernen' : 'Als Favorit markieren'}">${window.VendooIcons?.svg(item.favorite ? 'heart-filled' : 'heart', { size: 16 }) || ''}</button>` : ''}
|
||
</div>
|
||
<div class="media-card-content">
|
||
<div class="media-card-sources">${mediaSourceBadges(item)}</div>
|
||
<strong title="${escapeHtml(title)}">${escapeHtml(title)}</strong>
|
||
<span>${escapeHtml(dimensions)} · ${escapeHtml(formatMediaFileSize(item.file_size))}</span>
|
||
<small title="${escapeHtml(item.image_path.split('/').pop())}">${escapeHtml(formatFluxDate(item.created_at))} · ${escapeHtml(item.image_path.split('/').pop())}</small>
|
||
</div>
|
||
<div class="media-card-actions" aria-label="Bildaktionen">
|
||
<button type="button" class="media-action" data-media-edit="${escapeHtml(item.image_path)}" aria-label="Bild bearbeiten" title="Bearbeiten">${mediaActionIcon('edit')}<span class="media-action-label">Bearbeiten</span></button>
|
||
<button type="button" class="media-action" data-media-use="${escapeHtml(item.image_path)}" aria-label="Bild verwenden" title="Verwenden">${mediaActionIcon('use')}<span class="media-action-label">Verwenden</span></button>
|
||
<a class="media-action" href="${escapeHtml(item.public_url || photoUrl(item.image_path))}" download aria-label="Bild herunterladen" title="Download">${mediaActionIcon('download')}<span class="media-action-label">Download</span></a>
|
||
<button type="button" class="media-action danger" data-media-delete="${escapeHtml(item.image_path)}" aria-label="Bild löschen" title="Löschen">${mediaActionIcon('delete')}<span class="media-action-label">Löschen</span></button>
|
||
</div>
|
||
</article>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-media-select]').forEach(input => input.addEventListener('change', () => {
|
||
if (input.checked) state.mediaSelected.add(input.dataset.mediaSelect); else state.mediaSelected.delete(input.dataset.mediaSelect);
|
||
input.closest('.media-card')?.classList.toggle('is-selected', input.checked);
|
||
updateMediaToolbar();
|
||
}));
|
||
container.querySelectorAll('[data-media-preview]').forEach(button => button.addEventListener('click', () => openMediaLightbox(findMediaItem(button.dataset.mediaPreview))));
|
||
container.querySelectorAll('[data-media-edit]').forEach(button => button.addEventListener('click', () => openMediaEditor(findMediaItem(button.dataset.mediaEdit))));
|
||
container.querySelectorAll('[data-media-use]').forEach(button => button.addEventListener('click', () => useMediaInGenerator(findMediaItem(button.dataset.mediaUse))));
|
||
container.querySelectorAll('[data-media-favorite]').forEach(button => button.addEventListener('click', () => toggleMediaFavorite(findMediaItem(button.dataset.mediaFavorite))));
|
||
container.querySelectorAll('[data-media-delete]').forEach(button => button.addEventListener('click', () => deleteMediaItems([button.dataset.mediaDelete])));
|
||
updateMediaToolbar();
|
||
window.VendooIcons?.render(container);
|
||
}
|
||
|
||
function findMediaItem(imagePath) {
|
||
return state.mediaItems.find(item => item.image_path === imagePath) || (state.mediaCurrent?.image_path === imagePath ? state.mediaCurrent : null);
|
||
}
|
||
|
||
async function loadMediaLibrary({ silent = false } = {}) {
|
||
const container = document.getElementById('media-grid');
|
||
if (!silent && container && !state.mediaItems.length) container.innerHTML = '<div class="empty-state">Bildergalerie wird geladen …</div>';
|
||
try {
|
||
const params = new URLSearchParams({
|
||
page: String(state.mediaPage), page_size: String(state.mediaPageSize),
|
||
source: document.getElementById('media-source')?.value || 'all',
|
||
sort: document.getElementById('media-sort')?.value || 'newest',
|
||
favorites: document.getElementById('media-favorites-only')?.checked ? '1' : '0',
|
||
});
|
||
const search = document.getElementById('media-search')?.value.trim();
|
||
if (search) params.set('q', search);
|
||
const result = await api('GET', `/api/media-library?${params}`);
|
||
state.mediaItems = result.items || [];
|
||
state.mediaPagination = result.pagination || { page: 1, total_pages: 1, total: state.mediaItems.length };
|
||
state.mediaSummary = result.summary || null;
|
||
state.mediaPage = Number(state.mediaPagination.page || 1);
|
||
state.mediaSelected = new Set([...state.mediaSelected].filter(path => state.mediaItems.some(item => item.image_path === path)));
|
||
renderMediaSummary(); renderMediaLibrary(); renderMediaPagination();
|
||
} catch (error) {
|
||
if (container) container.innerHTML = `<div class="empty-state">Bildergalerie konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function toggleMediaFavorite(item) {
|
||
if (!item) return;
|
||
try {
|
||
const result = await api('PATCH', '/api/media-library/favorite', { image_path: item.image_path, favorite: !item.favorite });
|
||
item.favorite = result.favorite;
|
||
if (state.mediaCurrent?.image_path === item.image_path) state.mediaCurrent.favorite = result.favorite;
|
||
renderMediaLibrary(); renderMediaLightbox();
|
||
} catch (error) { toast(error.message); }
|
||
}
|
||
|
||
async function deleteMediaItems(imagePaths) {
|
||
const paths = [...new Set((imagePaths || []).filter(Boolean))];
|
||
if (!paths.length) return;
|
||
if (!window.confirm(`${paths.length} Bild${paths.length === 1 ? '' : 'er'} endgültig aus der Medienbibliothek und vom Datenträger löschen? In Artikeln verwendete Bilder werden geschützt.`)) return;
|
||
try {
|
||
const result = await api('DELETE', '/api/media-library/items', { image_paths: paths });
|
||
state.mediaSelected.clear();
|
||
if (state.mediaCurrent && paths.includes(state.mediaCurrent.image_path)) closeMediaLightbox();
|
||
const protectedCount = result.protected?.length || 0;
|
||
toast(`${result.deleted || 0} Bild${result.deleted === 1 ? '' : 'er'} gelöscht${protectedCount ? ` · ${protectedCount} Listing-Bild${protectedCount === 1 ? '' : 'er'} geschützt` : ''}.`);
|
||
await loadMediaLibrary();
|
||
} catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); }
|
||
}
|
||
|
||
function useMediaInGenerator(item) {
|
||
if (!item?.image_path) return;
|
||
if (!state.photos.includes(item.image_path)) state.photos.push(item.image_path);
|
||
renderPhotoPreview();
|
||
switchToTab('generator');
|
||
toast('Bild wurde dem neuen Artikel hinzugefügt.');
|
||
}
|
||
|
||
function openMediaEditor(item) {
|
||
if (!item) return;
|
||
state.mediaCurrent = item;
|
||
if ((item.sources || []).includes('flux') && item.flux_id) {
|
||
state.fluxStudioCurrent = { ...item, id: item.flux_id, image_path: item.image_path, public_url: item.public_url };
|
||
openPhotoEditor(0, 'flux');
|
||
} else openPhotoEditor(0, 'media');
|
||
}
|
||
|
||
const mediaZoomState = { zoom: 1, fitZoom: 1, naturalWidth: 0, naturalHeight: 0, dragging: false, startX: 0, startY: 0, scrollLeft: 0, scrollTop: 0 };
|
||
|
||
function updateMediaZoom(zoom, { center = false } = {}) {
|
||
const stage = document.getElementById('media-lightbox-stage');
|
||
const canvas = document.getElementById('media-lightbox-canvas');
|
||
const image = document.getElementById('media-lightbox-image');
|
||
if (!stage || !canvas || !image || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return;
|
||
const previousCenterX = stage.scrollLeft + stage.clientWidth / 2;
|
||
const previousCenterY = stage.scrollTop + stage.clientHeight / 2;
|
||
const previousWidth = Math.max(1, mediaZoomState.naturalWidth * mediaZoomState.zoom);
|
||
const previousHeight = Math.max(1, mediaZoomState.naturalHeight * mediaZoomState.zoom);
|
||
mediaZoomState.zoom = Math.min(4, Math.max(.1, Number(zoom) || 1));
|
||
const width = Math.max(1, Math.round(mediaZoomState.naturalWidth * mediaZoomState.zoom));
|
||
const height = Math.max(1, Math.round(mediaZoomState.naturalHeight * mediaZoomState.zoom));
|
||
canvas.style.width = `${width}px`;
|
||
canvas.style.height = `${height}px`;
|
||
image.style.width = '100%';
|
||
image.style.height = '100%';
|
||
const range = document.getElementById('media-zoom-range');
|
||
const output = document.getElementById('media-zoom-value');
|
||
if (range) range.value = String(Math.round(mediaZoomState.zoom * 100));
|
||
if (output) output.value = output.textContent = `${Math.round(mediaZoomState.zoom * 100)} %`;
|
||
if (center) {
|
||
const ratioX = previousCenterX / previousWidth;
|
||
const ratioY = previousCenterY / previousHeight;
|
||
requestAnimationFrame(() => {
|
||
stage.scrollLeft = Math.max(0, ratioX * width - stage.clientWidth / 2);
|
||
stage.scrollTop = Math.max(0, ratioY * height - stage.clientHeight / 2);
|
||
});
|
||
}
|
||
}
|
||
|
||
function fitMediaLightbox() {
|
||
const stage = document.getElementById('media-lightbox-stage');
|
||
if (!stage || !mediaZoomState.naturalWidth || !mediaZoomState.naturalHeight) return;
|
||
const availableWidth = Math.max(120, stage.clientWidth - 28);
|
||
const availableHeight = Math.max(120, stage.clientHeight - 28);
|
||
mediaZoomState.fitZoom = Math.min(1, availableWidth / mediaZoomState.naturalWidth, availableHeight / mediaZoomState.naturalHeight);
|
||
updateMediaZoom(mediaZoomState.fitZoom);
|
||
requestAnimationFrame(() => { stage.scrollLeft = 0; stage.scrollTop = 0; });
|
||
}
|
||
|
||
function renderMediaLightbox() {
|
||
const item = state.mediaCurrent;
|
||
const dialog = document.getElementById('media-lightbox');
|
||
if (!dialog || dialog.classList.contains('hidden') || !item) return;
|
||
document.getElementById('media-lightbox-title').textContent = item.title || item.prompt || item.image_path.split('/').pop();
|
||
const image = document.getElementById('media-lightbox-image');
|
||
image.onload = () => {
|
||
mediaZoomState.naturalWidth = image.naturalWidth || Number(item.width) || 1;
|
||
mediaZoomState.naturalHeight = image.naturalHeight || Number(item.height) || 1;
|
||
fitMediaLightbox();
|
||
};
|
||
image.src = item.public_url || photoUrl(item.image_path);
|
||
document.getElementById('media-lightbox-download').href = item.public_url || photoUrl(item.image_path);
|
||
document.getElementById('media-lightbox-download').download = item.image_path.split('/').pop();
|
||
const favorite = document.getElementById('media-lightbox-favorite');
|
||
const canFavorite = (item.sources || []).includes('flux');
|
||
favorite?.classList.toggle('hidden', !canFavorite);
|
||
if (favorite) favorite.innerHTML = `${window.VendooIcons?.svg(item.favorite ? 'heart-filled' : 'heart', { size: 17 }) || ''} ${item.favorite ? 'Favorit entfernen' : 'Favorit'}`;
|
||
document.getElementById('media-lightbox-meta').innerHTML = `
|
||
<div class="media-card-sources">${mediaSourceBadges(item)}</div>
|
||
<dl><dt>Datei</dt><dd>${escapeHtml(item.image_path)}</dd><dt>Abmessungen</dt><dd>${item.width && item.height ? `${item.width} × ${item.height}` : 'Unbekannt'}</dd><dt>Dateigröße</dt><dd>${escapeHtml(formatMediaFileSize(item.file_size))}</dd><dt>Format</dt><dd>${escapeHtml(String(item.format || '—').toUpperCase())}</dd><dt>Erstellt</dt><dd>${escapeHtml(formatFluxDate(item.created_at))}</dd>${item.seed != null ? `<dt>Seed</dt><dd>${item.seed}</dd>` : ''}</dl>
|
||
${item.prompt ? `<div class="media-lightbox-prompt"><strong>Prompt</strong><p>${escapeHtml(item.prompt)}</p></div>` : ''}`;
|
||
window.VendooIcons?.render(dialog);
|
||
}
|
||
|
||
function openMediaLightbox(item) {
|
||
if (!item) return;
|
||
state.mediaCurrent = item;
|
||
const dialog = document.getElementById('media-lightbox');
|
||
dialog?.classList.remove('hidden');
|
||
dialog?.setAttribute('aria-hidden', 'false');
|
||
document.body.classList.add('modal-open');
|
||
mediaZoomState.zoom = 1;
|
||
renderMediaLightbox();
|
||
}
|
||
|
||
function closeMediaLightbox() {
|
||
const dialog = document.getElementById('media-lightbox');
|
||
dialog?.classList.add('hidden');
|
||
dialog?.setAttribute('aria-hidden', 'true');
|
||
document.body.classList.remove('modal-open');
|
||
const image = document.getElementById('media-lightbox-image');
|
||
if (image) image.onload = null;
|
||
}
|
||
|
||
function setupMediaLibrary() {
|
||
const root = document.getElementById('media-library');
|
||
if (!root) return;
|
||
const resetAndLoad = () => { state.mediaPage = 1; state.mediaSelected.clear(); loadMediaLibrary(); };
|
||
document.getElementById('media-refresh')?.addEventListener('click', () => loadMediaLibrary());
|
||
document.getElementById('media-source')?.addEventListener('change', resetAndLoad);
|
||
document.getElementById('media-sort')?.addEventListener('change', resetAndLoad);
|
||
document.getElementById('media-favorites-only')?.addEventListener('change', resetAndLoad);
|
||
document.getElementById('media-page-size')?.addEventListener('change', event => { state.mediaPageSize = Number(event.target.value) || 24; resetAndLoad(); });
|
||
document.getElementById('media-search')?.addEventListener('input', debounce(resetAndLoad, 280));
|
||
document.querySelectorAll('[data-media-source]').forEach(button => button.addEventListener('click', () => {
|
||
const select = document.getElementById('media-source'); if (select) select.value = button.dataset.mediaSource; resetAndLoad();
|
||
}));
|
||
document.getElementById('media-view-grid')?.addEventListener('click', () => { state.mediaView = 'grid'; document.getElementById('media-view-grid').classList.add('active'); document.getElementById('media-view-list').classList.remove('active'); renderMediaLibrary(); });
|
||
document.getElementById('media-view-list')?.addEventListener('click', () => { state.mediaView = 'list'; document.getElementById('media-view-list').classList.add('active'); document.getElementById('media-view-grid').classList.remove('active'); renderMediaLibrary(); });
|
||
document.getElementById('media-select-page')?.addEventListener('change', event => { for (const item of state.mediaItems) { if (event.target.checked) state.mediaSelected.add(item.image_path); else state.mediaSelected.delete(item.image_path); } renderMediaLibrary(); });
|
||
document.getElementById('media-download-selected')?.addEventListener('click', () => downloadPublishPhotos([...state.mediaSelected]));
|
||
document.getElementById('media-delete-selected')?.addEventListener('click', () => deleteMediaItems([...state.mediaSelected]));
|
||
root.querySelectorAll('[data-media-page-action]').forEach(button => button.addEventListener('click', () => {
|
||
const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1));
|
||
const target = button.dataset.mediaPageAction === 'prev' ? state.mediaPage - 1 : state.mediaPage + 1;
|
||
if (target >= 1 && target <= pages && target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); }
|
||
}));
|
||
root.querySelectorAll('[data-media-page-jump]').forEach(select => select.addEventListener('change', () => {
|
||
const pages = Math.max(1, Number(state.mediaPagination.total_pages || 1));
|
||
const target = Math.min(pages, Math.max(1, Number(select.value || 1)));
|
||
if (target !== state.mediaPage) { state.mediaPage = target; loadMediaLibrary(); }
|
||
}));
|
||
document.querySelectorAll('[data-media-lightbox-close]').forEach(button => button.addEventListener('click', closeMediaLightbox));
|
||
document.getElementById('media-lightbox-edit')?.addEventListener('click', () => { closeMediaLightbox(); openMediaEditor(state.mediaCurrent); });
|
||
document.getElementById('media-lightbox-favorite')?.addEventListener('click', () => toggleMediaFavorite(state.mediaCurrent));
|
||
document.getElementById('media-lightbox-delete')?.addEventListener('click', () => deleteMediaItems(state.mediaCurrent ? [state.mediaCurrent.image_path] : []));
|
||
document.getElementById('media-zoom-in')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom + .15, { center: true }));
|
||
document.getElementById('media-zoom-out')?.addEventListener('click', () => updateMediaZoom(mediaZoomState.zoom - .15, { center: true }));
|
||
document.getElementById('media-zoom-fit')?.addEventListener('click', fitMediaLightbox);
|
||
document.getElementById('media-zoom-original')?.addEventListener('click', () => updateMediaZoom(1, { center: true }));
|
||
document.getElementById('media-zoom-range')?.addEventListener('input', event => updateMediaZoom(Number(event.target.value) / 100, { center: true }));
|
||
const zoomStage = document.getElementById('media-lightbox-stage');
|
||
zoomStage?.addEventListener('wheel', event => {
|
||
if (!event.ctrlKey && !event.metaKey) return;
|
||
event.preventDefault();
|
||
updateMediaZoom(mediaZoomState.zoom + (event.deltaY < 0 ? .1 : -.1), { center: true });
|
||
}, { passive: false });
|
||
zoomStage?.addEventListener('pointerdown', event => {
|
||
if (event.button !== 0 || mediaZoomState.zoom <= mediaZoomState.fitZoom + .01) return;
|
||
mediaZoomState.dragging = true; mediaZoomState.startX = event.clientX; mediaZoomState.startY = event.clientY;
|
||
mediaZoomState.scrollLeft = zoomStage.scrollLeft; mediaZoomState.scrollTop = zoomStage.scrollTop;
|
||
zoomStage.setPointerCapture(event.pointerId); zoomStage.classList.add('is-dragging');
|
||
});
|
||
zoomStage?.addEventListener('pointermove', event => {
|
||
if (!mediaZoomState.dragging) return;
|
||
zoomStage.scrollLeft = mediaZoomState.scrollLeft - (event.clientX - mediaZoomState.startX);
|
||
zoomStage.scrollTop = mediaZoomState.scrollTop - (event.clientY - mediaZoomState.startY);
|
||
});
|
||
const stopZoomDrag = event => { mediaZoomState.dragging = false; zoomStage?.classList.remove('is-dragging'); try { zoomStage?.releasePointerCapture(event.pointerId); } catch {} };
|
||
zoomStage?.addEventListener('pointerup', stopZoomDrag); zoomStage?.addEventListener('pointercancel', stopZoomDrag);
|
||
document.addEventListener('keydown', event => {
|
||
if (document.getElementById('media-lightbox')?.classList.contains('hidden')) return;
|
||
if (event.key === 'Escape') closeMediaLightbox();
|
||
if (event.key === '+' || event.key === '=') updateMediaZoom(mediaZoomState.zoom + .15, { center: true });
|
||
if (event.key === '-') updateMediaZoom(mediaZoomState.zoom - .15, { center: true });
|
||
if (event.key === '0') fitMediaLightbox();
|
||
});
|
||
}
|
||
|
||
// --- Browser extensions ---
|
||
const EXTENSION_BROWSERS = {
|
||
chrome: { name: 'Google Chrome', icon: 'chrome', managementUrl: 'chrome://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' },
|
||
edge: { name: 'Microsoft Edge', icon: 'edge', managementUrl: 'edge://extensions', action: 'Entpackte Erweiterung laden', note: 'Entwicklermodus aktivieren und den vorbereiteten Ordner auswählen.' },
|
||
firefox: { name: 'Mozilla Firefox', icon: 'firefox', managementUrl: 'about:debugging#/runtime/this-firefox', action: 'Temporäres Add-on laden', note: 'Für eine dauerhaft signierte Installation muss das Add-on über Mozilla signiert werden.' },
|
||
safari: { name: 'Apple Safari', icon: 'compass', managementUrl: 'Safari → Einstellungen → Erweiterungen', action: 'Safari Web Extension erstellen', note: 'Benötigt macOS und Xcode. Vendoo liefert WebExtension-Quelle und Konvertierungsskript mit.' },
|
||
};
|
||
|
||
function detectActiveBrowser() {
|
||
const ua = navigator.userAgent || '';
|
||
if (/Edg\//.test(ua)) return 'edge';
|
||
if (/Firefox\//.test(ua)) return 'firefox';
|
||
if (/Safari\//.test(ua) && !/Chrome\//.test(ua) && !/Chromium\//.test(ua)) return 'safari';
|
||
if (/Chrome\//.test(ua) || /Chromium\//.test(ua)) return 'chrome';
|
||
return 'chrome';
|
||
}
|
||
|
||
async function copyTextSafe(value) {
|
||
try { await navigator.clipboard.writeText(String(value || '')); return true; }
|
||
catch {
|
||
const area = document.createElement('textarea'); area.value = String(value || ''); area.style.position = 'fixed'; area.style.opacity = '0';
|
||
document.body.appendChild(area); area.select(); const ok = document.execCommand('copy'); area.remove(); return ok;
|
||
}
|
||
}
|
||
|
||
async function prepareBrowserExtension(browser, data = {}) {
|
||
const config = EXTENSION_BROWSERS[browser];
|
||
if (!config) return;
|
||
try {
|
||
const result = await api('POST', '/api/extensions/prepare', { browser });
|
||
await copyTextSafe(result.path || '');
|
||
const card = document.querySelector(`[data-extension-browser="${browser}"]`);
|
||
card?.querySelector('.extension-path code')?.replaceChildren(document.createTextNode(result.path || ''));
|
||
toast(result.opened
|
||
? `Installationsordner geöffnet und Pfad kopiert. Öffne jetzt ${config.managementUrl}.`
|
||
: `Ordnerpfad kopiert: ${result.path}`);
|
||
return result;
|
||
} catch (error) {
|
||
toast(`Extension konnte nicht vorbereitet werden: ${error.message}`);
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
function renderExtensions(data = {}) {
|
||
const grid = document.getElementById('extensions-grid');
|
||
if (!grid) return;
|
||
const active = detectActiveBrowser();
|
||
const activeBrowser = EXTENSION_BROWSERS[active];
|
||
const activeBox = document.getElementById('extensions-active-browser');
|
||
if (activeBox) activeBox.innerHTML = `<span>Aktiver Browser</span><strong>${window.VendooIcons?.svg(activeBrowser.icon, { size: 19 }) || ''} ${escapeHtml(activeBrowser.name)}</strong>`;
|
||
const version = data.version || '1.9.0';
|
||
grid.innerHTML = Object.entries(EXTENSION_BROWSERS).map(([id, browser]) => {
|
||
const info = data.browsers?.[id] || {};
|
||
return `<article class="extension-card${id === active ? ' is-active' : ''}" data-extension-browser="${id}">
|
||
<header><div class="extension-browser-icon">${window.VendooIcons?.svg(browser.icon, { size: 27 }) || ''}</div><div><span>${id === active ? 'Aktiver Browser' : 'Verfügbar'}</span><h3>${escapeHtml(browser.name)}</h3></div><b>v${escapeHtml(version)}</b></header>
|
||
<p>${escapeHtml(browser.note)}</p>
|
||
<div class="extension-path"><span>Installationsordner</span><code>${escapeHtml(info.path || 'Wird vom Server ermittelt …')}</code></div>
|
||
<ol><li>Installation vorbereiten</li><li><code>${escapeHtml(browser.managementUrl)}</code> öffnen</li><li>${escapeHtml(browser.action)}</li></ol>
|
||
<div class="extension-card-actions">
|
||
<button type="button" class="vd-button primary" data-extension-prepare="${id}">${window.VendooIcons?.svg('folder-open', { size: 17 }) || ''} Installation vorbereiten</button>
|
||
<button type="button" class="icon-button" data-extension-copy-path="${id}" title="Ordnerpfad kopieren" aria-label="Ordnerpfad kopieren">${window.VendooIcons?.svg('copy', { size: 17 }) || ''}</button>
|
||
<a class="icon-button" href="/api/extensions/download/${id}" title="Extension-Paket herunterladen" aria-label="Extension-Paket herunterladen">${window.VendooIcons?.svg('download', { size: 17 }) || ''}</a>
|
||
</div>
|
||
${id === 'safari' ? '<small>Safari lässt Erweiterungen nur über eine signierte macOS-App zu. Das Paket enthält dafür ein Xcode-Konvertierungsskript.</small>' : '<small>Die finale Bestätigung erfolgt im Browser; eine stille automatische Installation ist aus Sicherheitsgründen gesperrt.</small>'}
|
||
</article>`;
|
||
}).join('');
|
||
grid.querySelectorAll('[data-extension-prepare]').forEach(button => button.addEventListener('click', async () => {
|
||
const browser = button.dataset.extensionPrepare;
|
||
const old = button.innerHTML; button.disabled = true; button.textContent = 'Wird vorbereitet …';
|
||
try { await prepareBrowserExtension(browser, data); } catch {}
|
||
finally { button.disabled = false; button.innerHTML = old; }
|
||
}));
|
||
grid.querySelectorAll('[data-extension-copy-path]').forEach(button => button.addEventListener('click', async () => {
|
||
const path = data.browsers?.[button.dataset.extensionCopyPath]?.path || button.closest('.extension-card')?.querySelector('.extension-path code')?.textContent || '';
|
||
await copyTextSafe(path); toast('Ordnerpfad kopiert');
|
||
}));
|
||
window.VendooIcons?.render(document.getElementById('extensions'));
|
||
}
|
||
|
||
async function loadExtensions() {
|
||
const grid = document.getElementById('extensions-grid');
|
||
if (grid && !grid.children.length) grid.innerHTML = '<div class="empty-state">Extensions werden geprüft …</div>';
|
||
try { renderExtensions(await api('GET', '/api/extensions/info')); }
|
||
catch (error) { if (grid) grid.innerHTML = `<div class="empty-state">Extension-Informationen konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`; }
|
||
}
|
||
|
||
function setupExtensions() {
|
||
window.VendooIcons?.render(document.getElementById('extensions') || document);
|
||
document.getElementById('extensions-prepare-active')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget;
|
||
const browser = detectActiveBrowser();
|
||
const old = button.innerHTML;
|
||
button.disabled = true;
|
||
button.textContent = 'Wird vorbereitet …';
|
||
try { await prepareBrowserExtension(browser); } catch {}
|
||
finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); }
|
||
});
|
||
}
|
||
|
||
// --- Settings ---
|
||
async function detectPublicBaseUrl({ apply = true } = {}) {
|
||
const input = document.getElementById('setting-public-base-url');
|
||
const status = document.getElementById('public-base-url-status');
|
||
const button = document.getElementById('detect-public-base-url-btn');
|
||
if (button) button.disabled = true;
|
||
if (status) status.textContent = 'LAN-Adresse wird erkannt …';
|
||
try {
|
||
const info = await api('GET', '/api/network-info');
|
||
const preferred = info.public_base_url || info.preferred_url || '';
|
||
if (apply && input && preferred) input.value = preferred;
|
||
const adapter = info.addresses?.[0];
|
||
if (status) {
|
||
status.textContent = adapter
|
||
? `Erkannt: ${preferred} · Adapter: ${adapter.name}`
|
||
: `Keine LAN-IP erkannt. Aktuelle Adresse: ${preferred || 'localhost'}`;
|
||
}
|
||
return info;
|
||
} catch (err) {
|
||
if (status) status.textContent = `LAN-Adresse konnte nicht erkannt werden: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
|
||
function renderLocalFluxInstallState(install = {}) {
|
||
const wrap = document.getElementById('local-flux-progress-wrap');
|
||
const label = document.getElementById('local-flux-progress-label');
|
||
const value = document.getElementById('local-flux-progress-value');
|
||
const bar = document.getElementById('local-flux-progress-bar');
|
||
const phase = document.getElementById('local-flux-phase-label');
|
||
const updated = document.getElementById('local-flux-updated-at');
|
||
const checksWrap = document.getElementById('local-flux-checks');
|
||
const errorWrap = document.getElementById('local-flux-error');
|
||
const logTail = document.getElementById('local-flux-log-tail');
|
||
const sevenZipTail = document.getElementById('local-flux-7zip-tail');
|
||
const installButton = document.getElementById('install-local-image-provider-btn');
|
||
const installerState = document.getElementById('local-flux-installer-state');
|
||
const elapsed = document.getElementById('local-flux-elapsed');
|
||
const extractFiles = document.getElementById('local-flux-extract-files');
|
||
const extractSize = document.getElementById('local-flux-extract-size');
|
||
const heartbeat = document.getElementById('local-flux-heartbeat');
|
||
|
||
const progress = Math.max(0, Math.min(100, Number(install.progress ?? install.readiness ?? 0)));
|
||
if (wrap) wrap.classList.remove('hidden');
|
||
if (label) label.textContent = install.message || install.phase_label || 'Lokale FLUX-Installation';
|
||
if (value) value.textContent = `${progress}%`;
|
||
if (bar) {
|
||
bar.style.width = `${progress}%`;
|
||
bar.classList.toggle('is-stalled', install.status === 'stalled');
|
||
bar.classList.toggle('is-failed', install.status === 'failed');
|
||
}
|
||
if (phase) {
|
||
const phaseText = install.installation_complete ? 'Vollständig installiert' : (install.phase_label || install.phase || 'Nicht installiert');
|
||
phase.textContent = `${phaseText} · ${Number(install.readiness || 0)}% geprüft`;
|
||
}
|
||
if (updated) {
|
||
const date = install.updated_at ? new Date(install.updated_at) : null;
|
||
updated.textContent = date && !Number.isNaN(date.getTime()) ? `Stand: ${date.toLocaleString('de-DE')}` : 'Stand: –';
|
||
}
|
||
|
||
const metrics = install.extract_metrics || {};
|
||
if (installerState) {
|
||
const labels = { running: 'Läuft', completed: 'Fertig', failed: 'Fehler', stalled: 'Hängt', 'not-started': 'Nicht gestartet', unknown: 'Unbekannt' };
|
||
installerState.textContent = labels[install.status] || install.status || 'Bereit';
|
||
installerState.className = `local-flux-state-value is-${escapeHtml(install.status || 'idle')}`;
|
||
}
|
||
if (elapsed) elapsed.textContent = `${Number(install.elapsed_seconds || metrics.elapsed_seconds || 0)} s`;
|
||
if (extractFiles) extractFiles.textContent = `${Number(metrics.file_count || 0)} Dateien`;
|
||
if (extractSize) extractSize.textContent = `${Number(metrics.size_mb || 0)} MB`;
|
||
if (heartbeat) {
|
||
heartbeat.textContent = install.heartbeat_age_seconds === null || install.heartbeat_age_seconds === undefined
|
||
? '–'
|
||
: `${Number(install.heartbeat_age_seconds)} s`;
|
||
}
|
||
|
||
const checkLabels = {
|
||
seven_zip: '7-Zip',
|
||
archive: 'ComfyUI-Paket',
|
||
embedded_python: 'Python Runtime',
|
||
comfyui: 'ComfyUI',
|
||
model: 'FLUX-Modell',
|
||
workflow: 'Workflow',
|
||
start_script: 'Startskript',
|
||
stop_script: 'Stoppskript',
|
||
};
|
||
if (checksWrap) {
|
||
const checks = install.checks || {};
|
||
checksWrap.innerHTML = Object.entries(checkLabels).map(([key, title]) => {
|
||
const check = checks[key] || {};
|
||
let detail = check.ok ? 'Vorhanden' : 'Fehlt';
|
||
if (check.size_gb) detail += ` · ${check.size_gb} GB`;
|
||
else if (check.size_mb) detail += ` · ${check.size_mb} MB`;
|
||
return `<div class="local-flux-check ${check.ok ? 'is-ok' : 'is-missing'}"><strong>${escapeHtml(title)}</strong><span>${escapeHtml(detail)}</span></div>`;
|
||
}).join('');
|
||
}
|
||
|
||
if (errorWrap) {
|
||
const failed = install.status === 'failed' || install.status === 'stalled';
|
||
errorWrap.classList.toggle('hidden', !failed);
|
||
errorWrap.textContent = failed
|
||
? `${install.message || 'Installation fehlgeschlagen.'}${install.error_position ? `
|
||
${install.error_position}` : ''}
|
||
Die vorhandenen Downloads bleiben erhalten. „Installation fortsetzen“ startet nur den fehlenden Schritt erneut.`
|
||
: '';
|
||
}
|
||
if (logTail) {
|
||
const lines = Array.isArray(install.log_tail) ? install.log_tail : [];
|
||
logTail.textContent = lines.length ? lines.join('\n') : 'Noch kein Installationsprotokoll vorhanden.';
|
||
}
|
||
if (sevenZipTail) {
|
||
const lines = [
|
||
...(Array.isArray(install.seven_zip_error_tail) ? install.seven_zip_error_tail : []),
|
||
...(Array.isArray(install.seven_zip_output_tail) ? install.seven_zip_output_tail : []),
|
||
];
|
||
sevenZipTail.textContent = lines.length ? lines.slice(-40).join('\n') : 'Noch keine 7-Zip-Ausgabe vorhanden.';
|
||
}
|
||
|
||
const healthInstall = document.getElementById('local-flux-health-install');
|
||
const healthModel = document.getElementById('local-flux-health-model');
|
||
if (healthInstall) healthInstall.textContent = install.installation_complete ? 'Vollständig' : install.checks?.comfyui?.ok ? 'Unvollständig' : 'Nicht installiert';
|
||
if (healthModel) healthModel.textContent = install.checks?.model?.ok ? 'Vorhanden' : 'Fehlt';
|
||
|
||
if (installButton) {
|
||
if (install.status === 'failed' || install.status === 'stalled') installButton.textContent = 'Installation fortsetzen';
|
||
else if (install.checks?.comfyui?.ok && !install.checks?.model?.ok) installButton.textContent = 'FLUX-Modell nachinstallieren';
|
||
else if (install.can_resume) installButton.textContent = 'Installation fortsetzen';
|
||
else if (install.status === 'completed') installButton.textContent = 'Installation prüfen / ergänzen';
|
||
else installButton.textContent = 'Lokales FLUX installieren';
|
||
}
|
||
}
|
||
|
||
async function getDetailedLocalFluxInstallStatus() {
|
||
try {
|
||
const install = await api('GET', '/api/local-image/install-status');
|
||
renderLocalFluxInstallState(install);
|
||
return install;
|
||
} catch (err) {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
if (status) status.textContent = `Installationsstatus konnte nicht gelesen werden: ${err.message}`;
|
||
return null;
|
||
}
|
||
}
|
||
|
||
let localFluxStatusTimer = null;
|
||
let localFluxStatusWatchActive = false;
|
||
|
||
async function watchLocalFluxInstallStatus() {
|
||
if (localFluxStatusWatchActive) return;
|
||
localFluxStatusWatchActive = true;
|
||
const run = async () => {
|
||
try {
|
||
const install = await getDetailedLocalFluxInstallStatus();
|
||
const delay = install && ['running', 'stalled'].includes(install.status) ? 2000 : 10000;
|
||
localFluxStatusTimer = setTimeout(run, delay);
|
||
} catch {
|
||
localFluxStatusTimer = setTimeout(run, 10000);
|
||
}
|
||
};
|
||
run();
|
||
}
|
||
|
||
async function pollLocalFluxInstall(startedAt = Date.now()) {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const pollStarted = Date.now();
|
||
while (Date.now() - pollStarted < 60 * 60 * 1000) {
|
||
const install = await getDetailedLocalFluxInstallStatus();
|
||
if (!install) return null;
|
||
if (status) status.textContent = install.message || 'Lokale FLUX-Installation läuft ...';
|
||
|
||
const updatedAt = Date.parse(install.updated_at || '') || 0;
|
||
const isFreshTerminal = ['completed', 'failed'].includes(install.status) && (updatedAt >= startedAt - 2000 || Date.now() - pollStarted > 8000);
|
||
if (isFreshTerminal) {
|
||
if (install.status === 'completed') await testLocalImageProvider({ skipInstallFetch: true });
|
||
else if (status) status.textContent = `Installation fehlgeschlagen: ${install.message || 'Unbekannter Fehler'}`;
|
||
return install;
|
||
}
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
}
|
||
if (status) status.textContent = 'Die Installation läuft möglicherweise weiter. Nutze „Status prüfen“, um den aktuellen Stand zu laden.';
|
||
return null;
|
||
}
|
||
|
||
|
||
function updateLocalFluxCommandCenter(result = {}, install = {}) {
|
||
const stateName = result.engine_state || (!result.enabled ? 'disabled' : result.reachable ? 'running' : install.installation_complete ? 'stopped' : 'not-installed');
|
||
const labels = {
|
||
running: ['Aktiv und einsatzbereit', 'ComfyUI läuft. Vendoo kann lokale AI-Bilder erzeugen.'],
|
||
stopped: ['Installiert, aber gestoppt', 'Ein Klick auf „Server starten“ aktiviert die lokale Bild-Engine.'],
|
||
disabled: ['Deaktiviert', 'Vendoo nutzt lokales FLUX aktuell nicht. Installation und Dateien bleiben erhalten.'],
|
||
'not-installed': ['Nicht installiert', 'Installiere ComfyUI und FLUX direkt hier – Vendoo führt dich automatisch durch den Vorgang.'],
|
||
};
|
||
const [title, detail] = labels[stateName] || labels['not-installed'];
|
||
const orb = document.getElementById('local-flux-orb');
|
||
const titleEl = document.getElementById('local-flux-engine-title');
|
||
const detailEl = document.getElementById('local-flux-engine-detail');
|
||
const badge = document.getElementById('local-flux-engine-badge');
|
||
const enableLabel = document.getElementById('local-flux-enable-label');
|
||
const serverHealth = document.getElementById('local-flux-health-server');
|
||
const referenceHealth = document.getElementById('local-flux-health-reference');
|
||
const heroText = document.getElementById('settings-flux-status-text');
|
||
const heroChip = document.getElementById('settings-flux-status-chip');
|
||
if (titleEl) titleEl.textContent = title;
|
||
if (detailEl) detailEl.textContent = detail;
|
||
if (badge) { badge.textContent = title; badge.className = `flux-state-badge is-${stateName}`; }
|
||
if (orb) orb.className = `flux-orb is-${stateName}`;
|
||
if (enableLabel) enableLabel.textContent = result.enabled ? 'Aktiviert' : 'Deaktiviert';
|
||
if (serverHealth) serverHealth.textContent = result.reachable ? 'Online' : install.installation_complete ? 'Gestoppt' : 'Nicht verfügbar';
|
||
if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : 'Aktiv';
|
||
if (heroText) heroText.textContent = title;
|
||
if (heroChip) heroChip.className = `settings-status-chip is-${stateName}`;
|
||
|
||
const startBtn = document.getElementById('start-local-image-provider-btn');
|
||
const stopBtn = document.getElementById('stop-local-image-provider-btn');
|
||
const uninstallBtn = document.getElementById('uninstall-local-image-provider-btn');
|
||
if (startBtn) startBtn.disabled = !install.installation_complete || result.reachable || !result.enabled;
|
||
if (stopBtn) stopBtn.disabled = !result.reachable;
|
||
if (uninstallBtn) uninstallBtn.disabled = !install.checks?.comfyui?.ok && !install.checks?.model?.ok;
|
||
}
|
||
|
||
async function setLocalFluxEnabled(enabled) {
|
||
const toggle = document.getElementById('setting-ai-model-photo-local-enabled');
|
||
const status = document.getElementById('local-image-provider-status');
|
||
if (toggle) toggle.disabled = true;
|
||
try {
|
||
await api('PUT', '/api/settings', { ai_model_photo_local_enabled: enabled ? '1' : '0' });
|
||
if (status) status.textContent = enabled ? 'Lokales FLUX wurde aktiviert.' : 'Lokales FLUX wurde deaktiviert.';
|
||
if (!enabled) {
|
||
try { await api('POST', '/api/local-image/stop', {}); } catch {}
|
||
} else {
|
||
const current = await api('GET', '/api/local-image/status');
|
||
if (current.install?.installation_complete && !current.reachable) {
|
||
try { await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' }); } catch {}
|
||
await new Promise(resolve => setTimeout(resolve, 3500));
|
||
}
|
||
}
|
||
await testLocalImageProvider();
|
||
} catch (err) {
|
||
if (toggle) toggle.checked = !enabled;
|
||
if (status) status.textContent = `Aktivierung konnte nicht geändert werden: ${err.message}`;
|
||
} finally {
|
||
if (toggle) toggle.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function uninstallLocalImageProvider() {
|
||
const button = document.getElementById('uninstall-local-image-provider-btn');
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const confirmed = window.confirm('Lokales FLUX wirklich deinstallieren? ComfyUI und das große FLUX-Modell werden entfernt. Deine Vendoo-Daten und heruntergeladenen Installationsarchive bleiben erhalten.');
|
||
if (!confirmed) return;
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const result = await api('POST', '/api/local-image/uninstall', { remove_downloads: false });
|
||
if (status) status.textContent = result.message || 'Deinstallation wurde gestartet.';
|
||
await new Promise(resolve => setTimeout(resolve, 2500));
|
||
const started = Date.now();
|
||
while (Date.now() - started < 120000) {
|
||
const install = await getDetailedLocalFluxInstallStatus();
|
||
if (install && !install.checks?.comfyui?.ok && !install.checks?.model?.ok) break;
|
||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||
}
|
||
const toggle = document.getElementById('setting-ai-model-photo-local-enabled');
|
||
if (toggle) toggle.checked = false;
|
||
await testLocalImageProvider();
|
||
toast('Lokales FLUX wurde deinstalliert.');
|
||
} catch (err) {
|
||
if (status) status.textContent = `Deinstallation fehlgeschlagen: ${err.message}`;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function setupSettingsNavigation() {
|
||
const buttons = [...document.querySelectorAll('[data-settings-panel]')];
|
||
const panels = [...document.querySelectorAll('[data-settings-panel-content]')];
|
||
const activate = (name) => {
|
||
buttons.forEach(button => button.classList.toggle('active', button.dataset.settingsPanel === name));
|
||
panels.forEach(panel => panel.classList.toggle('active', panel.dataset.settingsPanelContent === name));
|
||
try { localStorage.setItem('vendoo-settings-panel', name); } catch {}
|
||
};
|
||
buttons.forEach(button => button.addEventListener('click', () => activate(button.dataset.settingsPanel)));
|
||
let initial = 'general';
|
||
try { initial = localStorage.getItem('vendoo-settings-panel') || initial; } catch {}
|
||
if (!panels.some(panel => panel.dataset.settingsPanelContent === initial)) initial = 'general';
|
||
activate(initial);
|
||
}
|
||
|
||
function updateReferenceStrengthUi() {
|
||
const range = document.getElementById('setting-ai-model-photo-reference-strength');
|
||
const output = document.getElementById('setting-ai-model-photo-reference-strength-value');
|
||
if (range && output) output.textContent = `${range.value}%`;
|
||
const referenceHealth = document.getElementById('local-flux-health-reference');
|
||
if (referenceHealth) referenceHealth.textContent = document.getElementById('setting-ai-model-photo-use-source-reference')?.checked === false ? 'Aus' : `${range?.value || 70}%`;
|
||
}
|
||
|
||
async function testLocalImageProvider(options = {}) {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const button = document.getElementById('test-local-image-provider-btn');
|
||
if (button) button.disabled = true;
|
||
if (status) status.textContent = 'Lokaler FLUX/ComfyUI-Status wird geprüft ...';
|
||
try {
|
||
const result = await api('GET', '/api/local-image/status');
|
||
const install = options.skipInstallFetch ? (result.install || {}) : (await getDetailedLocalFluxInstallStatus() || result.install || {});
|
||
if (options.skipInstallFetch) renderLocalFluxInstallState(install);
|
||
updateLocalFluxCommandCenter(result, install);
|
||
if (status) {
|
||
if (install.status === 'failed') {
|
||
status.textContent = `Installation fehlgeschlagen · ${install.message || 'Details im Installationsstatus'}`;
|
||
} else if (result.reachable) {
|
||
status.textContent = `Aktiv · ${result.url} erreichbar · Modell und Workflow bereit${result.reference_image_supported ? ' · Produktreferenz unterstützt' : ''}`;
|
||
} else {
|
||
status.textContent = install.installation_complete ? 'Installiert, aber der Bildserver ist gestoppt.' : `Noch nicht vollständig installiert · ${install.phase_label || 'Installation erforderlich'}`;
|
||
}
|
||
}
|
||
return { ...result, install };
|
||
} catch (err) {
|
||
if (status) status.textContent = `Lokaler FLUX-Test fehlgeschlagen: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function installLocalImageProvider() {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const button = document.getElementById('install-local-image-provider-btn');
|
||
const installModel = document.getElementById('local-flux-install-model')?.checked !== false;
|
||
const enableAutostart = document.getElementById('local-flux-enable-autostart')?.checked !== false;
|
||
const startNow = document.getElementById('local-flux-start-now')?.checked !== false;
|
||
const warning = installModel
|
||
? 'ComfyUI und FLUX.1-schnell FP8 werden heruntergeladen. Das benötigt viel Speicherplatz und kann lange dauern. Installation starten?'
|
||
: 'ComfyUI wird installiert, das FLUX-Modell jedoch nicht. Installation starten?';
|
||
if (!window.confirm(warning)) return null;
|
||
if (button) button.disabled = true;
|
||
if (status) status.textContent = 'Lokale FLUX-Installation wird gestartet ...';
|
||
try {
|
||
const startedAt = Date.now();
|
||
const result = await api('POST', '/api/local-image/install', {
|
||
install_model: installModel,
|
||
enable_autostart: enableAutostart,
|
||
start_now: startNow,
|
||
gpu: 'auto',
|
||
});
|
||
if (status) status.textContent = result.message || 'Lokale FLUX-Installation wurde gestartet.';
|
||
await new Promise(resolve => setTimeout(resolve, 900));
|
||
await pollLocalFluxInstall(startedAt);
|
||
return result;
|
||
} catch (err) {
|
||
if (status) status.textContent = `Lokale FLUX-Installation fehlgeschlagen: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function updateLocalImageProvider() {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const button = document.getElementById('update-local-image-provider-btn');
|
||
if (!window.confirm('Lokales ComfyUI jetzt aktualisieren? Bestehende Modelle bleiben erhalten.')) return null;
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const startedAt = Date.now();
|
||
const result = await api('POST', '/api/local-image/update', {});
|
||
if (status) status.textContent = result.message || 'Lokales FLUX-Update wurde gestartet.';
|
||
await new Promise(resolve => setTimeout(resolve, 900));
|
||
await pollLocalFluxInstall(startedAt);
|
||
return result;
|
||
} catch (err) {
|
||
if (status) status.textContent = `Update fehlgeschlagen: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function startLocalImageProvider() {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const button = document.getElementById('start-local-image-provider-btn');
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const result = await api('POST', '/api/local-image/start', { profile: document.getElementById('setting-flux-runtime-profile')?.value || 'auto' });
|
||
if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird gestartet.';
|
||
await waitForFluxEngineReady({ statusElement: status });
|
||
return testLocalImageProvider();
|
||
} catch (err) {
|
||
if (status) status.textContent = `Start fehlgeschlagen: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function stopLocalImageProvider() {
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const button = document.getElementById('stop-local-image-provider-btn');
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const result = await api('POST', '/api/local-image/stop', {});
|
||
if (status) status.textContent = result.message || 'Lokaler FLUX-Server wird beendet.';
|
||
await new Promise(resolve => setTimeout(resolve, 1800));
|
||
return testLocalImageProvider();
|
||
} catch (err) {
|
||
if (status) status.textContent = `Stoppen fehlgeschlagen: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
async function regenerateLocalImageToken() {
|
||
const field = document.getElementById('setting-ai-model-photo-local-token');
|
||
const status = document.getElementById('local-image-provider-status');
|
||
const button = document.getElementById('regenerate-local-image-token-btn');
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const result = await api('POST', '/api/local-image/regenerate-token', {});
|
||
if (field) field.value = result.token || '';
|
||
if (status) status.textContent = 'Neuer lokaler Zugriffstoken erzeugt. Bitte Einstellungen speichern.';
|
||
return result;
|
||
} catch (err) {
|
||
if (status) status.textContent = `Token konnte nicht erneuert werden: ${err.message}`;
|
||
return null;
|
||
} finally {
|
||
if (button) button.disabled = false;
|
||
}
|
||
}
|
||
|
||
function updateAiPhotoProviderSettingsUi() {
|
||
const provider = document.getElementById('setting-ai-model-photo-provider')?.value || 'auto';
|
||
const openrouterSelect = document.getElementById('setting-ai-model-photo-openrouter-model');
|
||
const refreshBtn = document.getElementById('refresh-openrouter-models-btn');
|
||
const freeOnly = document.getElementById('setting-ai-model-photo-free-only');
|
||
const openAiField = document.getElementById('setting-ai-model-photo-openai-model');
|
||
const localUrlField = document.getElementById('setting-ai-model-photo-local-url');
|
||
const localTokenField = document.getElementById('setting-ai-model-photo-local-token');
|
||
const localWorkflowField = document.getElementById('setting-ai-model-photo-local-workflow-path');
|
||
const localInstallField = document.getElementById('setting-ai-model-photo-local-install-path');
|
||
const localEnabled = document.getElementById('setting-ai-model-photo-local-enabled');
|
||
const localRequireToken = document.getElementById('setting-ai-model-photo-local-require-token');
|
||
const testBtn = document.getElementById('test-local-image-provider-btn');
|
||
const installBtn = document.getElementById('install-local-image-provider-btn');
|
||
const updateBtn = document.getElementById('update-local-image-provider-btn');
|
||
const startBtn = document.getElementById('start-local-image-provider-btn');
|
||
const stopBtn = document.getElementById('stop-local-image-provider-btn');
|
||
const regenBtn = document.getElementById('regenerate-local-image-token-btn');
|
||
const useLocal = provider === 'auto' || provider === 'local';
|
||
const useOpenRouter = provider !== 'openai' && provider !== 'local';
|
||
const useOpenAi = provider !== 'openrouter' && provider !== 'local';
|
||
if (openrouterSelect) openrouterSelect.disabled = !useOpenRouter;
|
||
if (refreshBtn) refreshBtn.disabled = !useOpenRouter;
|
||
if (freeOnly) freeOnly.disabled = !useOpenRouter;
|
||
if (openAiField) openAiField.disabled = !useOpenAi;
|
||
if (localUrlField) localUrlField.disabled = !useLocal;
|
||
if (localTokenField) localTokenField.disabled = !useLocal;
|
||
if (localWorkflowField) localWorkflowField.disabled = !useLocal;
|
||
if (localInstallField) localInstallField.disabled = !useLocal;
|
||
if (localEnabled) localEnabled.disabled = false;
|
||
if (localRequireToken) localRequireToken.disabled = !useLocal;
|
||
if (testBtn) testBtn.disabled = false;
|
||
if (installBtn) installBtn.disabled = false;
|
||
if (updateBtn) updateBtn.disabled = false;
|
||
// Start/Stop werden ausschließlich vom realen Runtime-Status gesteuert.
|
||
if (regenBtn) regenBtn.disabled = false;
|
||
}
|
||
|
||
function renderOpenRouterModelPreview(models = [], selectedId = '', freeOnly = true) {
|
||
const wrap = document.getElementById('openrouter-models-preview');
|
||
const status = document.getElementById('openrouter-models-status');
|
||
const select = document.getElementById('setting-ai-model-photo-openrouter-model');
|
||
if (!wrap || !status || !select) return;
|
||
|
||
const normalized = Array.isArray(models) ? models : [];
|
||
const options = ['<option value="">Automatisch bestes Modell wählen</option>'];
|
||
normalized.forEach(model => {
|
||
const label = `${model.name || model.id}${model.is_free ? ' · Free' : ''}`;
|
||
options.push(`<option value="${escapeHtml(model.id || '')}">${escapeHtml(label)}</option>`);
|
||
});
|
||
select.innerHTML = options.join('');
|
||
if (selectedId) select.value = selectedId;
|
||
if (!select.value) select.value = '';
|
||
|
||
if (!normalized.length) {
|
||
wrap.classList.add('hidden');
|
||
wrap.innerHTML = '';
|
||
status.textContent = freeOnly
|
||
? 'Keine kostenlosen OpenRouter-Bildmodelle gefunden. In Auto nutzt Vendoo dann OpenAI als Fallback.'
|
||
: 'Keine OpenRouter-Bildmodelle gefunden.';
|
||
return;
|
||
}
|
||
|
||
const top = normalized.slice(0, 4);
|
||
wrap.classList.remove('hidden');
|
||
wrap.innerHTML = top.map(model => `
|
||
<div class="openrouter-model-card">
|
||
<div class="openrouter-model-top">
|
||
<div>
|
||
<strong>${escapeHtml(model.name || model.id || 'OpenRouter Modell')}</strong>
|
||
<span>${escapeHtml(model.id || '')}</span>
|
||
</div>
|
||
<div class="openrouter-model-badges">
|
||
<span class="openrouter-badge ${model.is_free ? 'is-free' : ''}">${model.is_free ? 'Free' : 'Paid'}</span>
|
||
<span class="openrouter-badge">Image</span>
|
||
</div>
|
||
</div>
|
||
<p class="openrouter-model-description">${escapeHtml((model.description || 'Kein Beschreibungstext verfügbar.').slice(0, 220))}</p>
|
||
</div>
|
||
`).join('');
|
||
status.textContent = `${normalized.length} OpenRouter-Bildmodell(e) verfügbar${freeOnly ? ' (Free gefiltert)' : ''}.`;
|
||
}
|
||
|
||
async function loadOpenRouterImageModels(selectedId = '') {
|
||
const status = document.getElementById('openrouter-models-status');
|
||
const freeOnly = document.getElementById('setting-ai-model-photo-free-only')?.checked !== false;
|
||
if (status) status.textContent = 'OpenRouter-Bildmodelle werden geladen ...';
|
||
try {
|
||
const result = await api('GET', `/api/ai-model-photos/providers/openrouter/models?free_only=${freeOnly ? '1' : '0'}`);
|
||
state.openRouterImageModels = result.models || [];
|
||
renderOpenRouterModelPreview(state.openRouterImageModels, selectedId, !!result.free_only);
|
||
if (freeOnly && state.openRouterImageModels.length === 0) {
|
||
try {
|
||
const diagnostics = await api('GET', '/api/ai-model-photos/providers/openrouter/diagnostics');
|
||
if (status) {
|
||
status.textContent = diagnostics.free_image_model_count > 0
|
||
? `${diagnostics.free_image_model_count} Free-Bildmodell(e) erkannt, aber aktuell nicht nutzbar.`
|
||
: `Aktuell 0 kostenlose Bildmodelle bei OpenRouter; ${diagnostics.image_model_count || 0} kostenpflichtige Bildmodelle erkannt. Auto nutzt OpenAI als Fallback, sofern ein OpenAI-Key hinterlegt ist.`;
|
||
}
|
||
} catch {}
|
||
}
|
||
} catch (err) {
|
||
state.openRouterImageModels = [];
|
||
renderOpenRouterModelPreview([], selectedId, freeOnly);
|
||
if (status) status.textContent = `OpenRouter-Modelle konnten nicht geladen werden: ${err.message}`;
|
||
}
|
||
}
|
||
|
||
function populateSettingsForm(settings, platforms, providers) {
|
||
const ps = document.getElementById('setting-default-platform');
|
||
const fs = document.getElementById('filter-platform');
|
||
const as = document.getElementById('setting-default-ai');
|
||
|
||
ps.innerHTML = ''; fs.innerHTML = '<option value="">Alle Plattformen</option>';
|
||
for (const p of platforms) {
|
||
ps.innerHTML += `<option value="${p.id}">${p.name}</option>`;
|
||
fs.innerHTML += `<option value="${p.id}">${p.name}</option>`;
|
||
}
|
||
as.innerHTML = providers.map(p => `<option value="${p.id}">${p.name}</option>`).join('');
|
||
|
||
ps.value = settings.default_platform || 'vinted';
|
||
as.value = settings.default_ai || 'claude';
|
||
document.getElementById('setting-default-language').value = settings.default_language || 'de';
|
||
document.getElementById('setting-seller-notes').value = settings.seller_notes || '';
|
||
document.getElementById('setting-anthropic-key').placeholder = settings.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...';
|
||
document.getElementById('setting-openai-key').placeholder = settings.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...';
|
||
document.getElementById('setting-openrouter-key').placeholder = settings.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...';
|
||
document.getElementById('setting-etsy-key').placeholder = settings.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx';
|
||
document.getElementById('setting-ebay-client-id').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...';
|
||
document.getElementById('setting-ebay-client-secret').placeholder = settings.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...';
|
||
document.getElementById('setting-ebay-sandbox').checked = !!settings.ebay_sandbox;
|
||
document.getElementById('setting-ollama-url').value = settings.ollama_url || 'http://localhost:11434';
|
||
document.getElementById('setting-public-base-url').value = settings.public_base_url || '';
|
||
detectPublicBaseUrl({ apply: !settings.public_base_url });
|
||
document.getElementById('setting-ai-model-photo-provider').value = settings.ai_model_photo_provider || 'auto';
|
||
const savedPhotoMode = ['ghost_mannequin', 'flatlay'].includes(settings.ai_model_photo_default_mode) ? settings.ai_model_photo_default_mode : 'flatlay';
|
||
document.getElementById('setting-ai-model-photo-mode').value = savedPhotoMode;
|
||
document.getElementById('setting-ai-model-photo-variants').value = String(settings.ai_model_photo_default_variants || '1');
|
||
document.getElementById('setting-ai-model-photo-preset').value = settings.ai_model_photo_default_preset || 'mixed';
|
||
document.getElementById('setting-ai-model-photo-enabled').checked = String(settings.ai_model_photos_enabled || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-free-only').checked = String(settings.ai_model_photo_openrouter_free_only || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-block-underwear').checked = String(settings.ai_model_photo_block_underwear || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-block-kids').checked = String(settings.ai_model_photo_block_kids || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-preserve-logos').checked = String(settings.ai_model_photo_preserve_logos || '1') === '1';
|
||
getAiPromptDraft('generator').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1';
|
||
getAiPromptDraft('detail').preserveLogos = String(settings.ai_model_photo_preserve_logos || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-openai-model').value = settings.ai_model_photo_openai_model || 'gpt-image-1';
|
||
document.getElementById('setting-ai-model-photo-local-url').value = settings.ai_model_photo_local_url || 'http://127.0.0.1:8188';
|
||
document.getElementById('setting-ai-model-photo-local-token').placeholder = settings.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)';
|
||
document.getElementById('setting-ai-model-photo-local-workflow-path').value = settings.ai_model_photo_local_workflow_path || 'local-ai/workflows/vendoo_flux_schnell_api.json';
|
||
document.getElementById('setting-ai-model-photo-local-install-path').value = settings.ai_model_photo_local_install_path || 'local-ai/comfyui';
|
||
document.getElementById('setting-ai-model-photo-local-enabled').checked = String(settings.ai_model_photo_local_enabled || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-local-require-token').checked = String(settings.ai_model_photo_local_require_token || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-use-source-reference').checked = String(settings.ai_model_photo_use_source_reference || '1') === '1';
|
||
document.getElementById('setting-ai-model-photo-reference-strength').value = String(settings.ai_model_photo_reference_strength || '70');
|
||
document.getElementById('setting-ai-model-photo-local-resolution').value = String(settings.ai_model_photo_local_resolution || '768');
|
||
document.getElementById('setting-flux-runtime-profile').value = settings.flux_runtime_profile || 'auto';
|
||
document.getElementById('setting-flux-default-profile').value = settings.flux_default_profile || 'balanced';
|
||
document.getElementById('setting-flux-default-style').value = settings.flux_default_style || 'photorealistic';
|
||
document.getElementById('setting-flux-default-format').value = settings.flux_default_format || '768x768';
|
||
document.getElementById('setting-flux-default-variants').value = settings.flux_default_variants || '1';
|
||
document.getElementById('setting-flux-max-parallel-jobs').value = settings.flux_max_parallel_jobs || '1';
|
||
document.getElementById('setting-flux-prompt-assistant').checked = String(settings.flux_prompt_assistant || '1') === '1';
|
||
document.getElementById('setting-flux-seed-lock').checked = String(settings.flux_seed_lock || '0') === '1';
|
||
const studioStyle = document.getElementById('flux-studio-style'); if (studioStyle) studioStyle.value = settings.flux_default_style || 'photorealistic';
|
||
const studioFormat = document.getElementById('flux-studio-format'); if (studioFormat) studioFormat.value = settings.flux_default_format || '768x768';
|
||
const studioLock = document.getElementById('flux-studio-seed-lock'); if (studioLock) studioLock.checked = String(settings.flux_seed_lock || '0') === '1';
|
||
applyFluxProfile(settings.flux_default_profile || 'balanced', { persist: false });
|
||
setFluxVariantCount(settings.flux_default_variants || '1', { persist: false });
|
||
updateReferenceStrengthUi();
|
||
const aiStatusText = document.getElementById('settings-ai-status-text');
|
||
const aiStatusChip = document.getElementById('settings-ai-status-chip');
|
||
if (aiStatusText) aiStatusText.textContent = String(settings.ai_model_photos_enabled || '1') === '1' ? 'Aktiv' : 'Deaktiviert';
|
||
if (aiStatusChip) aiStatusChip.className = `settings-status-chip ${String(settings.ai_model_photos_enabled || '1') === '1' ? 'is-running' : 'is-disabled'}`;
|
||
updateAiPhotoProviderSettingsUi();
|
||
loadOpenRouterImageModels(settings.ai_model_photo_openrouter_model || '');
|
||
testLocalImageProvider();
|
||
|
||
const etsyWrap = document.getElementById('etsy-connect-wrap');
|
||
const etsyBadge = document.getElementById('etsy-status-badge');
|
||
if (settings.has_etsy_key) {
|
||
etsyWrap.classList.remove('hidden');
|
||
if (settings.etsy_connected) {
|
||
etsyBadge.textContent = 'Verbunden';
|
||
etsyBadge.className = 'etsy-status connected';
|
||
} else {
|
||
etsyBadge.textContent = 'Nicht verbunden';
|
||
etsyBadge.className = 'etsy-status disconnected';
|
||
}
|
||
} else {
|
||
etsyWrap.classList.add('hidden');
|
||
}
|
||
|
||
const ebayWrap = document.getElementById('ebay-connect-wrap');
|
||
const ebayBadge = document.getElementById('ebay-status-badge');
|
||
if (settings.has_ebay_credentials) {
|
||
ebayWrap.classList.remove('hidden');
|
||
if (settings.ebay_connected) {
|
||
ebayBadge.textContent = settings.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden';
|
||
ebayBadge.className = 'etsy-status connected';
|
||
} else {
|
||
ebayBadge.textContent = 'Nicht verbunden';
|
||
ebayBadge.className = 'etsy-status disconnected';
|
||
}
|
||
} else {
|
||
ebayWrap.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function setupSettings() {
|
||
setupSettingsNavigation();
|
||
updateReferenceStrengthUi();
|
||
watchLocalFluxInstallStatus();
|
||
document.getElementById('detect-public-base-url-btn')?.addEventListener('click', () => detectPublicBaseUrl({ apply: true }));
|
||
document.getElementById('setting-ai-model-photo-provider')?.addEventListener('change', updateAiPhotoProviderSettingsUi);
|
||
document.getElementById('setting-ai-model-photo-free-only')?.addEventListener('change', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || ''));
|
||
document.getElementById('refresh-openrouter-models-btn')?.addEventListener('click', () => loadOpenRouterImageModels(document.getElementById('setting-ai-model-photo-openrouter-model')?.value || ''));
|
||
document.getElementById('test-local-image-provider-btn')?.addEventListener('click', testLocalImageProvider);
|
||
document.getElementById('install-local-image-provider-btn')?.addEventListener('click', installLocalImageProvider);
|
||
document.getElementById('update-local-image-provider-btn')?.addEventListener('click', updateLocalImageProvider);
|
||
document.getElementById('start-local-image-provider-btn')?.addEventListener('click', startLocalImageProvider);
|
||
document.getElementById('stop-local-image-provider-btn')?.addEventListener('click', stopLocalImageProvider);
|
||
document.getElementById('regenerate-local-image-token-btn')?.addEventListener('click', regenerateLocalImageToken);
|
||
document.getElementById('uninstall-local-image-provider-btn')?.addEventListener('click', uninstallLocalImageProvider);
|
||
document.getElementById('setting-ai-model-photo-local-enabled')?.addEventListener('change', (event) => setLocalFluxEnabled(event.target.checked));
|
||
document.getElementById('setting-ai-model-photo-reference-strength')?.addEventListener('input', updateReferenceStrengthUi);
|
||
document.getElementById('setting-ai-model-photo-use-source-reference')?.addEventListener('change', updateReferenceStrengthUi);
|
||
document.getElementById('setting-ai-model-photo-enabled')?.addEventListener('change', (event) => {
|
||
const text = document.getElementById('settings-ai-status-text');
|
||
const chip = document.getElementById('settings-ai-status-chip');
|
||
if (text) text.textContent = event.target.checked ? 'Aktiv' : 'Deaktiviert';
|
||
if (chip) chip.className = `settings-status-chip ${event.target.checked ? 'is-running' : 'is-disabled'}`;
|
||
});
|
||
document.querySelectorAll('#settings input, #settings select, #settings textarea').forEach(field => {
|
||
if (field.closest('#theme-manager-root')) return;
|
||
field.addEventListener('change', () => {
|
||
const title = document.getElementById('settings-save-title');
|
||
const copy = document.getElementById('settings-save-copy');
|
||
if (title) title.textContent = 'Ungespeicherte Änderungen';
|
||
if (copy) copy.textContent = 'Mit „Einstellungen speichern“ übernehmen.';
|
||
});
|
||
});
|
||
|
||
document.getElementById('save-settings-btn').addEventListener('click', async () => {
|
||
try {
|
||
const settings = {
|
||
default_platform: document.getElementById('setting-default-platform').value,
|
||
default_ai: document.getElementById('setting-default-ai').value,
|
||
default_language: document.getElementById('setting-default-language').value,
|
||
seller_notes: document.getElementById('setting-seller-notes').value,
|
||
ollama_url: document.getElementById('setting-ollama-url').value,
|
||
public_base_url: document.getElementById('setting-public-base-url').value.trim(),
|
||
ai_model_photos_enabled: document.getElementById('setting-ai-model-photo-enabled').checked ? '1' : '0',
|
||
ai_model_photo_provider: document.getElementById('setting-ai-model-photo-provider').value,
|
||
ai_model_photo_default_mode: document.getElementById('setting-ai-model-photo-mode').value,
|
||
ai_model_photo_default_variants: document.getElementById('setting-ai-model-photo-variants').value,
|
||
ai_model_photo_default_preset: document.getElementById('setting-ai-model-photo-preset').value,
|
||
ai_model_photo_openrouter_free_only: document.getElementById('setting-ai-model-photo-free-only').checked ? '1' : '0',
|
||
ai_model_photo_block_underwear: document.getElementById('setting-ai-model-photo-block-underwear').checked ? '1' : '0',
|
||
ai_model_photo_block_kids: document.getElementById('setting-ai-model-photo-block-kids').checked ? '1' : '0',
|
||
ai_model_photo_preserve_logos: document.getElementById('setting-ai-model-photo-preserve-logos').checked ? '1' : '0',
|
||
ai_model_photo_openrouter_model: document.getElementById('setting-ai-model-photo-openrouter-model').value,
|
||
ai_model_photo_openai_model: document.getElementById('setting-ai-model-photo-openai-model').value.trim() || 'gpt-image-1',
|
||
ai_model_photo_local_enabled: document.getElementById('setting-ai-model-photo-local-enabled').checked ? '1' : '0',
|
||
ai_model_photo_local_url: document.getElementById('setting-ai-model-photo-local-url').value.trim() || 'http://127.0.0.1:8188',
|
||
ai_model_photo_local_workflow_path: document.getElementById('setting-ai-model-photo-local-workflow-path').value.trim() || 'local-ai/workflows/vendoo_flux_schnell_api.json',
|
||
ai_model_photo_local_install_path: document.getElementById('setting-ai-model-photo-local-install-path').value.trim() || 'local-ai/comfyui',
|
||
ai_model_photo_local_require_token: document.getElementById('setting-ai-model-photo-local-require-token').checked ? '1' : '0',
|
||
ai_model_photo_use_source_reference: document.getElementById('setting-ai-model-photo-use-source-reference').checked ? '1' : '0',
|
||
ai_model_photo_reference_strength: document.getElementById('setting-ai-model-photo-reference-strength').value || '70',
|
||
ai_model_photo_local_resolution: document.getElementById('setting-ai-model-photo-local-resolution').value || '768',
|
||
flux_runtime_profile: document.getElementById('setting-flux-runtime-profile').value || 'auto',
|
||
flux_default_profile: document.getElementById('setting-flux-default-profile').value || 'balanced',
|
||
flux_prompt_assistant: document.getElementById('setting-flux-prompt-assistant').checked ? '1' : '0',
|
||
flux_default_style: document.getElementById('setting-flux-default-style').value || 'photorealistic',
|
||
flux_default_format: document.getElementById('setting-flux-default-format').value || '768x768',
|
||
flux_default_variants: document.getElementById('setting-flux-default-variants').value || '1',
|
||
flux_seed_lock: document.getElementById('setting-flux-seed-lock').checked ? '1' : '0',
|
||
flux_max_parallel_jobs: document.getElementById('setting-flux-max-parallel-jobs').value || '1',
|
||
};
|
||
const ak = document.getElementById('setting-anthropic-key').value;
|
||
const ok = document.getElementById('setting-openai-key').value;
|
||
const ork = document.getElementById('setting-openrouter-key').value;
|
||
const ek = document.getElementById('setting-etsy-key').value;
|
||
const localToken = document.getElementById('setting-ai-model-photo-local-token').value.trim();
|
||
const ebayId = document.getElementById('setting-ebay-client-id').value;
|
||
const ebaySec = document.getElementById('setting-ebay-client-secret').value;
|
||
const ebayRu = document.getElementById('setting-ebay-ru-name').value;
|
||
const ebaySandbox = document.getElementById('setting-ebay-sandbox').checked;
|
||
if (ak) settings.anthropic_key = ak;
|
||
if (ok) settings.openai_key = ok;
|
||
if (ork) settings.openrouter_key = ork;
|
||
if (ek) settings.etsy_key = ek;
|
||
if (localToken) settings.ai_model_photo_local_token = localToken;
|
||
if (ebayId) settings.ebay_client_id = ebayId;
|
||
if (ebaySec) settings.ebay_client_secret = ebaySec;
|
||
if (ebayRu) settings.ebay_ru_name = ebayRu;
|
||
settings.ebay_sandbox = ebaySandbox ? 'true' : 'false';
|
||
|
||
const saved = await api('PUT', '/api/settings', settings);
|
||
state.platform = settings.default_platform;
|
||
state.aiProvider = settings.default_ai;
|
||
state.language = settings.default_language;
|
||
renderPlatformButtons();
|
||
renderProviderButtons();
|
||
document.getElementById('language-select').value = state.language;
|
||
|
||
document.getElementById('setting-anthropic-key').value = '';
|
||
document.getElementById('setting-openai-key').value = '';
|
||
document.getElementById('setting-openrouter-key').value = '';
|
||
document.getElementById('setting-etsy-key').value = '';
|
||
document.getElementById('setting-ai-model-photo-local-token').value = '';
|
||
document.getElementById('setting-ebay-client-id').value = '';
|
||
document.getElementById('setting-ebay-client-secret').value = '';
|
||
document.getElementById('setting-anthropic-key').placeholder = saved.has_anthropic_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-ant-...';
|
||
document.getElementById('setting-openai-key').placeholder = saved.has_openai_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-...';
|
||
document.getElementById('setting-openrouter-key').placeholder = saved.has_openrouter_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'sk-or-...';
|
||
document.getElementById('setting-etsy-key').placeholder = saved.has_etsy_key ? 'Key gesetzt (leer lassen um beizubehalten)' : 'xxxxxxxx';
|
||
document.getElementById('setting-ai-model-photo-local-token').placeholder = saved.local_image_token_set ? 'Token gesetzt (leer lassen um beizubehalten)' : 'Lokaler Token (optional)';
|
||
document.getElementById('setting-ebay-client-id').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-App-...';
|
||
document.getElementById('setting-ebay-client-secret').placeholder = saved.has_ebay_credentials ? 'Key gesetzt (leer lassen um beizubehalten)' : 'eBay-Cert-...';
|
||
|
||
const etsyWrap = document.getElementById('etsy-connect-wrap');
|
||
if (saved.has_etsy_key) {
|
||
etsyWrap.classList.remove('hidden');
|
||
const badge = document.getElementById('etsy-status-badge');
|
||
if (saved.etsy_connected) { badge.textContent = 'Verbunden'; badge.className = 'etsy-status connected'; }
|
||
else { badge.textContent = 'Nicht verbunden'; badge.className = 'etsy-status disconnected'; }
|
||
}
|
||
|
||
updateAiPhotoProviderSettingsUi();
|
||
if (saved.public_base_url !== undefined) document.getElementById('setting-public-base-url').value = saved.public_base_url || '';
|
||
detectPublicBaseUrl({ apply: false });
|
||
loadOpenRouterImageModels(saved.ai_model_photo_openrouter_model || document.getElementById('setting-ai-model-photo-openrouter-model').value || '');
|
||
testLocalImageProvider();
|
||
|
||
const ebayWrap = document.getElementById('ebay-connect-wrap');
|
||
if (saved.has_ebay_credentials) {
|
||
ebayWrap.classList.remove('hidden');
|
||
const ebBadge = document.getElementById('ebay-status-badge');
|
||
if (saved.ebay_connected) {
|
||
ebBadge.textContent = saved.ebay_sandbox ? 'Verbunden (Sandbox)' : 'Verbunden';
|
||
ebBadge.className = 'etsy-status connected';
|
||
} else {
|
||
ebBadge.textContent = 'Nicht verbunden';
|
||
ebBadge.className = 'etsy-status disconnected';
|
||
}
|
||
}
|
||
const saveTitle = document.getElementById('settings-save-title');
|
||
const saveCopy = document.getElementById('settings-save-copy');
|
||
if (saveTitle) saveTitle.textContent = 'Einstellungen gespeichert';
|
||
if (saveCopy) saveCopy.textContent = `Zuletzt gespeichert: ${new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}`;
|
||
toast('Einstellungen gespeichert!');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
});
|
||
|
||
document.getElementById('etsy-connect-btn').addEventListener('click', () => {
|
||
window.open('/auth/etsy', 'etsy-auth', 'width=600,height=700');
|
||
});
|
||
document.getElementById('ebay-connect-btn').addEventListener('click', () => {
|
||
window.open('/auth/ebay', 'ebay-auth', 'width=600,height=700');
|
||
});
|
||
window.addEventListener('message', async (e) => {
|
||
if (e.data === 'etsy-connected') {
|
||
const badge = document.getElementById('etsy-status-badge');
|
||
badge.textContent = 'Verbunden';
|
||
badge.className = 'etsy-status connected';
|
||
toast('Etsy erfolgreich verbunden!');
|
||
}
|
||
if (e.data === 'ebay-connected') {
|
||
const badge = document.getElementById('ebay-status-badge');
|
||
badge.textContent = 'Verbunden';
|
||
badge.className = 'etsy-status connected';
|
||
toast('eBay erfolgreich verbunden!');
|
||
}
|
||
});
|
||
|
||
document.querySelectorAll('.toggle-visibility').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
const inp = btn.previousElementSibling;
|
||
const isPw = inp.type === 'password';
|
||
inp.type = isPw ? 'text' : 'password';
|
||
btn.textContent = isPw ? 'Verbergen' : 'Zeigen';
|
||
});
|
||
});
|
||
}
|
||
|
||
// --- Category Picker ---
|
||
const catCache = {};
|
||
|
||
async function loadCategories(platformId) {
|
||
if (catCache[platformId]) return catCache[platformId];
|
||
try {
|
||
const data = await api('GET', `/api/categories/${platformId}`);
|
||
catCache[platformId] = data;
|
||
return data;
|
||
} catch { return { tree: [], flat: [] }; }
|
||
}
|
||
|
||
function createCategoryPicker(container, platformId, initialValue) {
|
||
const wrap = typeof container === 'string' ? document.getElementById(container) : container;
|
||
if (!wrap) return { getValue: () => null, setValue: () => {} };
|
||
|
||
let selected = null;
|
||
let dropdownOpen = false;
|
||
|
||
wrap.innerHTML = `
|
||
<div class="cat-picker-wrap">
|
||
<input type="text" class="cat-search meta-input" placeholder="Kategorie suchen...">
|
||
<div class="cat-dropdown"></div>
|
||
<div class="cat-selected-display" style="display:none"></div>
|
||
</div>`;
|
||
|
||
const input = wrap.querySelector('.cat-search');
|
||
const dropdown = wrap.querySelector('.cat-dropdown');
|
||
const selectedDisplay = wrap.querySelector('.cat-selected-display');
|
||
|
||
async function doSearch(q) {
|
||
const data = await loadCategories(platformId);
|
||
if (!q || q.length < 2) {
|
||
renderTree(data.tree);
|
||
return;
|
||
}
|
||
try {
|
||
const results = await api('GET', `/api/categories/${platformId}/match?q=${encodeURIComponent(q)}`);
|
||
renderResults(results);
|
||
} catch { renderResults([]); }
|
||
}
|
||
|
||
function renderResults(results) {
|
||
if (!results.length) {
|
||
dropdown.innerHTML = '<div class="cat-empty">Keine Kategorien gefunden</div>';
|
||
openDropdown();
|
||
return;
|
||
}
|
||
dropdown.innerHTML = results.map(r => `
|
||
<div class="cat-result-item" data-path="${escapeHtml(r.pathStr)}" data-name="${escapeHtml(r.name)}">
|
||
<div class="cat-result-name">${escapeHtml(r.name)}</div>
|
||
<div class="cat-result-path">${escapeHtml(r.pathStr)}</div>
|
||
</div>`).join('');
|
||
dropdown.querySelectorAll('.cat-result-item').forEach(el => {
|
||
el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name));
|
||
});
|
||
openDropdown();
|
||
}
|
||
|
||
function renderTree(tree) {
|
||
if (!tree || !tree.length) {
|
||
dropdown.innerHTML = '<div class="cat-empty">Keine Kategorien verfügbar</div>';
|
||
openDropdown();
|
||
return;
|
||
}
|
||
dropdown.innerHTML = tree.map(group => {
|
||
const children = (group.children || []).map(sub => {
|
||
if (sub.children) {
|
||
const leaves = sub.children.map(leaf =>
|
||
`<div class="cat-leaf" data-path="${escapeHtml([group.name, sub.name, leaf.name].join(' > '))}" data-name="${escapeHtml(leaf.name)}">${escapeHtml(leaf.name)}</div>`
|
||
).join('');
|
||
return `<div class="cat-sub">
|
||
<div class="cat-sub-hd">${escapeHtml(sub.name)}</div>
|
||
<div class="cat-sub-children">${leaves}</div>
|
||
</div>`;
|
||
}
|
||
return `<div class="cat-leaf" data-path="${escapeHtml([group.name, sub.name].join(' > '))}" data-name="${escapeHtml(sub.name)}">${escapeHtml(sub.name)}</div>`;
|
||
}).join('');
|
||
return `<div class="cat-group">
|
||
<div class="cat-group-hd" data-expanded="false">${escapeHtml(group.name)}</div>
|
||
<div class="cat-group-body" style="display:none">${children}</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
dropdown.querySelectorAll('.cat-group-hd').forEach(hd => {
|
||
hd.addEventListener('click', () => {
|
||
const body = hd.nextElementSibling;
|
||
const expanded = hd.dataset.expanded === 'true';
|
||
hd.dataset.expanded = expanded ? 'false' : 'true';
|
||
body.style.display = expanded ? 'none' : 'block';
|
||
hd.classList.toggle('expanded', !expanded);
|
||
});
|
||
});
|
||
|
||
dropdown.querySelectorAll('.cat-leaf').forEach(el => {
|
||
el.addEventListener('click', () => selectCategory(el.dataset.path, el.dataset.name));
|
||
});
|
||
openDropdown();
|
||
}
|
||
|
||
function selectCategory(path, name) {
|
||
selected = path;
|
||
input.value = '';
|
||
input.style.display = 'none';
|
||
selectedDisplay.style.display = 'flex';
|
||
selectedDisplay.innerHTML = `
|
||
<span class="cat-sel-text">${escapeHtml(path)}</span>
|
||
<button class="cat-sel-clear" type="button">×</button>`;
|
||
selectedDisplay.querySelector('.cat-sel-clear').addEventListener('click', clearSelection);
|
||
closeDropdown();
|
||
}
|
||
|
||
function clearSelection() {
|
||
selected = null;
|
||
input.style.display = '';
|
||
selectedDisplay.style.display = 'none';
|
||
input.value = '';
|
||
input.focus();
|
||
}
|
||
|
||
function openDropdown() { dropdown.style.display = 'block'; dropdownOpen = true; }
|
||
function closeDropdown() { dropdown.style.display = 'none'; dropdownOpen = false; }
|
||
|
||
input.addEventListener('focus', () => doSearch(input.value));
|
||
input.addEventListener('input', debounce(() => doSearch(input.value), 200));
|
||
|
||
document.addEventListener('click', e => {
|
||
if (dropdownOpen && !wrap.contains(e.target)) closeDropdown();
|
||
});
|
||
|
||
if (initialValue) {
|
||
selectCategory(initialValue, initialValue.split(' > ').pop());
|
||
}
|
||
|
||
return {
|
||
getValue: () => selected,
|
||
setValue: (val) => {
|
||
if (val) selectCategory(val, val.split(' > ').pop());
|
||
else clearSelection();
|
||
},
|
||
};
|
||
}
|
||
|
||
let metaCategoryPicker = null;
|
||
|
||
function addQuillTooltips() {
|
||
const tooltips = {
|
||
'ql-bold': 'Fett', 'ql-italic': 'Kursiv', 'ql-underline': 'Unterstrichen',
|
||
'ql-strike': 'Durchgestrichen', 'ql-blockquote': 'Zitat', 'ql-link': 'Link einfügen',
|
||
'ql-clean': 'Formatierung entfernen', 'ql-list[value="ordered"]': 'Nummerierte Liste',
|
||
'ql-list[value="bullet"]': 'Aufzählung', 'ql-color': 'Textfarbe',
|
||
'ql-background': 'Hintergrundfarbe', 'ql-align': 'Ausrichtung', 'ql-header': 'Überschrift',
|
||
};
|
||
for (const [sel, tip] of Object.entries(tooltips)) {
|
||
document.querySelectorAll(`.ql-${sel}, .${sel}, button.${sel.split('[')[0]}`).forEach(el => { el.title = tip; });
|
||
}
|
||
document.querySelectorAll('.ql-bold').forEach(el => { el.title = 'Fett'; });
|
||
document.querySelectorAll('.ql-italic').forEach(el => { el.title = 'Kursiv'; });
|
||
document.querySelectorAll('.ql-underline').forEach(el => { el.title = 'Unterstrichen'; });
|
||
document.querySelectorAll('.ql-strike').forEach(el => { el.title = 'Durchgestrichen'; });
|
||
document.querySelectorAll('.ql-blockquote').forEach(el => { el.title = 'Zitat'; });
|
||
document.querySelectorAll('.ql-link').forEach(el => { el.title = 'Link einfügen'; });
|
||
document.querySelectorAll('.ql-clean').forEach(el => { el.title = 'Formatierung entfernen'; });
|
||
document.querySelectorAll('.ql-list[value="ordered"]').forEach(el => { el.title = 'Nummerierte Liste'; });
|
||
document.querySelectorAll('.ql-list[value="bullet"]').forEach(el => { el.title = 'Aufzählung'; });
|
||
document.querySelectorAll('.ql-color .ql-picker-label').forEach(el => { el.title = 'Textfarbe'; });
|
||
document.querySelectorAll('.ql-background .ql-picker-label').forEach(el => { el.title = 'Hintergrundfarbe'; });
|
||
document.querySelectorAll('.ql-align .ql-picker-label').forEach(el => { el.title = 'Ausrichtung'; });
|
||
document.querySelectorAll('.ql-header .ql-picker-label').forEach(el => { el.title = 'Überschrift'; });
|
||
}
|
||
|
||
function generateQRDataUrl(text, size) {
|
||
if (typeof QRious === 'undefined') return null;
|
||
try {
|
||
const qr = new QRious({ value: text, size: size || 120, level: 'M' });
|
||
return qr.toDataURL();
|
||
} catch { return null; }
|
||
}
|
||
|
||
let printStudioState = {
|
||
mode: 'labels',
|
||
listings: [],
|
||
listing: null,
|
||
copies: 1,
|
||
format: '90x50',
|
||
showPrice: true,
|
||
};
|
||
|
||
function ensurePrintStudio() {
|
||
let studio = document.getElementById('print-studio');
|
||
if (studio) return studio;
|
||
|
||
studio = document.createElement('div');
|
||
studio.id = 'print-studio';
|
||
studio.className = 'print-studio hidden';
|
||
studio.innerHTML = `
|
||
<div class="print-studio-backdrop" data-print-close></div>
|
||
<section class="print-studio-shell" role="dialog" aria-modal="true" aria-labelledby="print-studio-title">
|
||
<header class="print-studio-header">
|
||
<div>
|
||
<span class="print-studio-kicker">Vendoo Print Studio</span>
|
||
<h2 id="print-studio-title">Druckvorschau</h2>
|
||
</div>
|
||
<button type="button" class="print-studio-close" data-print-close aria-label="Druckvorschau schließen">×</button>
|
||
</header>
|
||
<div class="print-studio-layout">
|
||
<aside class="print-studio-controls">
|
||
<div class="print-control-group" id="print-mode-summary"></div>
|
||
<label class="print-control-field" id="print-format-field">
|
||
<span>Etikettenformat</span>
|
||
<select id="print-format-select">
|
||
<option value="90x50">90 × 50 mm · 10 pro A4</option>
|
||
<option value="62x29">62 × 29 mm · 27 pro A4</option>
|
||
</select>
|
||
</label>
|
||
<label class="print-control-field" id="print-copies-field">
|
||
<span>Exemplare je Artikel</span>
|
||
<input id="print-copies-input" type="number" min="1" max="20" value="1">
|
||
</label>
|
||
<label class="print-control-check" id="print-price-field">
|
||
<input id="print-show-price" type="checkbox" checked>
|
||
<span>Preis auf Etikett anzeigen</span>
|
||
</label>
|
||
<div class="print-hint">
|
||
<strong>Ohne neues Fenster</strong>
|
||
<span>Die Vorschau bleibt in Vendoo. Erst „Drucken“ öffnet den normalen Systemdruckdialog.</span>
|
||
</div>
|
||
</aside>
|
||
<div class="print-preview-pane">
|
||
<div class="print-preview-toolbar">
|
||
<span id="print-preview-count"></span>
|
||
<span>Maßstabsgetreue A4-Vorschau</span>
|
||
</div>
|
||
<div id="print-preview-pages" class="print-preview-pages"></div>
|
||
</div>
|
||
</div>
|
||
<footer class="print-studio-footer">
|
||
<button type="button" class="vd-button" data-print-close>Abbrechen</button>
|
||
<button type="button" class="vd-button primary" id="print-now-btn">Drucken</button>
|
||
</footer>
|
||
</section>
|
||
`;
|
||
document.body.appendChild(studio);
|
||
|
||
studio.querySelectorAll('[data-print-close]').forEach(button => {
|
||
button.addEventListener('click', closePrintStudio);
|
||
});
|
||
studio.querySelector('#print-format-select').addEventListener('change', event => {
|
||
printStudioState.format = event.target.value;
|
||
renderPrintStudio();
|
||
});
|
||
studio.querySelector('#print-copies-input').addEventListener('input', event => {
|
||
printStudioState.copies = Math.max(1, Math.min(20, Number(event.target.value) || 1));
|
||
renderPrintStudio();
|
||
});
|
||
studio.querySelector('#print-show-price').addEventListener('change', event => {
|
||
printStudioState.showPrice = event.target.checked;
|
||
renderPrintStudio();
|
||
});
|
||
studio.querySelector('#print-now-btn').addEventListener('click', () => {
|
||
document.body.classList.add('print-studio-active');
|
||
window.print();
|
||
});
|
||
window.addEventListener('afterprint', () => document.body.classList.remove('print-studio-active'));
|
||
document.addEventListener('keydown', event => {
|
||
if (event.key === 'Escape' && !studio.classList.contains('hidden')) closePrintStudio();
|
||
});
|
||
return studio;
|
||
}
|
||
|
||
function closePrintStudio() {
|
||
const studio = document.getElementById('print-studio');
|
||
if (!studio) return;
|
||
studio.classList.add('hidden');
|
||
document.body.classList.remove('print-studio-open', 'print-studio-active');
|
||
}
|
||
|
||
function openPrintStudio(mode, payload) {
|
||
printStudioState = {
|
||
mode,
|
||
listings: payload.listings || [],
|
||
listing: payload.listing || null,
|
||
copies: 1,
|
||
format: '90x50',
|
||
showPrice: true,
|
||
};
|
||
const studio = ensurePrintStudio();
|
||
studio.classList.remove('hidden');
|
||
document.body.classList.add('print-studio-open');
|
||
studio.querySelector('#print-format-select').value = printStudioState.format;
|
||
studio.querySelector('#print-copies-input').value = '1';
|
||
studio.querySelector('#print-show-price').checked = true;
|
||
renderPrintStudio();
|
||
}
|
||
|
||
function printLabels(listings) {
|
||
if (!Array.isArray(listings) || !listings.length) return;
|
||
openPrintStudio('labels', { listings });
|
||
}
|
||
|
||
function printDatasheet(listing) {
|
||
if (!listing) return;
|
||
openPrintStudio('datasheet', { listing });
|
||
}
|
||
|
||
function getPrintPlatformName(platform) {
|
||
return state.platforms.find(item => item.id === platform)?.name || platformLabel(platform);
|
||
}
|
||
|
||
function buildPrintLabel(listing) {
|
||
const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`;
|
||
const title = (listing.title || 'Ohne Titel').substring(0, 60);
|
||
const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 170);
|
||
const price = listing.price ? formatCurrency(listing.price) : '';
|
||
const platform = getPrintPlatformName(listing.platform);
|
||
return `
|
||
<article class="print-label-card">
|
||
<div class="print-label-accent"></div>
|
||
<div class="print-label-qr">${qr ? `<img src="${qr}" alt="QR-Code ${escapeHtml(sku)}">` : ''}</div>
|
||
<div class="print-label-copy">
|
||
<div class="print-label-brand">Vendoo</div>
|
||
<div class="print-label-sku">${escapeHtml(sku)}</div>
|
||
<div class="print-label-title">${escapeHtml(title)}</div>
|
||
<div class="print-label-meta">
|
||
<span>${escapeHtml(platform)}</span>
|
||
${printStudioState.showPrice && price ? `<strong>${escapeHtml(price)}</strong>` : ''}
|
||
</div>
|
||
</div>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function buildPrintDatasheet(listing) {
|
||
const sku = listing.sku || `VD-${String(listing.id).padStart(4, '0')}`;
|
||
const qr = generateQRDataUrl(JSON.stringify({ sku, id: listing.id }), 190);
|
||
const photos = (listing.photos || []).slice(0, 4);
|
||
const dateValue = listing.created_at ? new Date(`${listing.created_at}Z`) : new Date();
|
||
const date = Number.isNaN(dateValue.getTime()) ? '' : dateValue.toLocaleDateString('de-DE');
|
||
const meta = [
|
||
['Marke', listing.brand],
|
||
['Kategorie', listing.category],
|
||
['Größe', listing.size],
|
||
['Farbe', listing.color],
|
||
['Zustand', listing.condition],
|
||
['Preis', listing.price ? formatCurrency(listing.price) : ''],
|
||
['Plattform', getPrintPlatformName(listing.platform)],
|
||
['Lagerort', listing.storage_location],
|
||
['Erstellt', date],
|
||
].filter(([, value]) => value);
|
||
|
||
return `
|
||
<article class="print-datasheet">
|
||
<header class="print-datasheet-header">
|
||
<div>
|
||
<span class="print-datasheet-brand">Vendoo · Artikeldatenblatt</span>
|
||
<h1>${escapeHtml(listing.title || 'Ohne Titel')}</h1>
|
||
<div class="print-datasheet-sku">${escapeHtml(sku)}</div>
|
||
</div>
|
||
${qr ? `<img class="print-datasheet-qr" src="${qr}" alt="QR-Code ${escapeHtml(sku)}">` : ''}
|
||
</header>
|
||
${photos.length ? `<div class="print-datasheet-photos">${photos.map(photo => `<div><img src="${escapeHtml(photoUrl(photo))}" alt=""></div>`).join('')}</div>` : ''}
|
||
${listing.description ? `<section class="print-datasheet-description"><h2>Beschreibung</h2><p>${escapeHtml(String(listing.description).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim())}</p></section>` : ''}
|
||
<section class="print-datasheet-meta">
|
||
${meta.map(([label, value]) => `<div><span>${escapeHtml(label)}</span><strong>${escapeHtml(String(value))}</strong></div>`).join('')}
|
||
</section>
|
||
</article>
|
||
`;
|
||
}
|
||
|
||
function chunkPrintItems(items, size) {
|
||
const chunks = [];
|
||
for (let index = 0; index < items.length; index += size) chunks.push(items.slice(index, index + size));
|
||
return chunks;
|
||
}
|
||
|
||
function renderPrintStudio() {
|
||
const studio = ensurePrintStudio();
|
||
const pages = studio.querySelector('#print-preview-pages');
|
||
const summary = studio.querySelector('#print-mode-summary');
|
||
const count = studio.querySelector('#print-preview-count');
|
||
const formatField = studio.querySelector('#print-format-field');
|
||
const copiesField = studio.querySelector('#print-copies-field');
|
||
const priceField = studio.querySelector('#print-price-field');
|
||
|
||
const labelsMode = printStudioState.mode === 'labels';
|
||
formatField.classList.toggle('hidden', !labelsMode);
|
||
copiesField.classList.toggle('hidden', !labelsMode);
|
||
priceField.classList.toggle('hidden', !labelsMode);
|
||
|
||
if (!labelsMode) {
|
||
const listing = printStudioState.listing;
|
||
summary.innerHTML = `<span class="print-summary-icon">▤</span><div><strong>Datenblatt</strong><span>${escapeHtml(listing?.sku || listing?.title || 'Artikel')}</span></div>`;
|
||
count.textContent = '1 Seite';
|
||
pages.innerHTML = `<div class="print-page datasheet-page">${buildPrintDatasheet(listing)}</div>`;
|
||
return;
|
||
}
|
||
|
||
const expanded = [];
|
||
printStudioState.listings.forEach(listing => {
|
||
for (let copy = 0; copy < printStudioState.copies; copy += 1) expanded.push(listing);
|
||
});
|
||
const compact = printStudioState.format === '62x29';
|
||
const pageSize = compact ? 27 : 10;
|
||
const pageChunks = chunkPrintItems(expanded, pageSize);
|
||
summary.innerHTML = `<span class="print-summary-icon">⌗</span><div><strong>Etiketten</strong><span>${printStudioState.listings.length} Artikel · ${expanded.length} Etiketten</span></div>`;
|
||
count.textContent = `${pageChunks.length} ${pageChunks.length === 1 ? 'Seite' : 'Seiten'}`;
|
||
pages.innerHTML = pageChunks.map(chunk => `
|
||
<div class="print-page label-page ${compact ? 'compact-labels' : 'standard-labels'}">
|
||
<div class="print-label-grid">${chunk.map(buildPrintLabel).join('')}</div>
|
||
</div>
|
||
`).join('');
|
||
}
|
||
|
||
// --- Trash / Papierkorb ---
|
||
function setupTrash() {
|
||
document.getElementById('empty-trash-btn').addEventListener('click', async () => {
|
||
if (!confirm('Papierkorb endgültig leeren? Dies kann nicht rückgängig gemacht werden!')) return;
|
||
await api('DELETE', '/api/trash');
|
||
toast('Papierkorb geleert');
|
||
loadTrash();
|
||
updateTrashBadge();
|
||
});
|
||
}
|
||
|
||
async function loadTrash() {
|
||
try {
|
||
const listings = await api('GET', '/api/trash');
|
||
const c = document.getElementById('trash-list');
|
||
if (!listings.length) {
|
||
c.innerHTML = '<div class="empty-state">Papierkorb ist leer</div>';
|
||
return;
|
||
}
|
||
|
||
c.innerHTML = listings.map(l => {
|
||
const photo = l.photos?.[0] ? `<img class="thumb-small" src="/uploads/${l.photos[0]}" alt="">` : '<div class="thumb-small"></div>';
|
||
const pName = state.platforms.find(p => p.id === l.platform)?.name || l.platform;
|
||
const deletedDate = l.deleted_at ? new Date(l.deleted_at + 'Z').toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '';
|
||
const priceStr = l.price ? `${l.price}€` : '';
|
||
|
||
return `<div class="trash-item" data-id="${l.id}">
|
||
${photo}
|
||
<div class="item-info">
|
||
<div class="item-title">${escapeHtml(l.title || 'Ohne Titel')}</div>
|
||
<div class="item-meta">${pName} · ${priceStr} · Gelöscht: ${deletedDate}</div>
|
||
</div>
|
||
<div class="trash-actions">
|
||
<button class="restore-btn" data-id="${l.id}">Wiederherstellen</button>
|
||
<button class="perm-delete-btn" data-id="${l.id}">Endgültig löschen</button>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
c.querySelectorAll('.restore-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
await api('POST', `/api/trash/${btn.dataset.id}/restore`);
|
||
toast('Listing wiederhergestellt');
|
||
loadTrash();
|
||
updateTrashBadge();
|
||
});
|
||
});
|
||
|
||
c.querySelectorAll('.perm-delete-btn').forEach(btn => {
|
||
btn.addEventListener('click', async () => {
|
||
if (!confirm('Listing endgültig löschen? Dies kann nicht rückgängig gemacht werden!')) return;
|
||
await api('DELETE', `/api/trash/${btn.dataset.id}`);
|
||
toast('Endgültig gelöscht');
|
||
loadTrash();
|
||
updateTrashBadge();
|
||
});
|
||
});
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function updateTrashBadge() {
|
||
try {
|
||
const data = await api('GET', '/api/trash/count');
|
||
const badge = document.getElementById('trash-badge');
|
||
if (data.count > 0) {
|
||
badge.textContent = data.count;
|
||
badge.classList.remove('hidden');
|
||
} else {
|
||
badge.classList.add('hidden');
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// --- Utils ---
|
||
function toast(msg) {
|
||
const el = document.getElementById('toast');
|
||
el.textContent = msg;
|
||
el.classList.add('show');
|
||
setTimeout(() => el.classList.remove('show'), 2500);
|
||
}
|
||
|
||
function debounce(fn, ms) {
|
||
let t; return (...a) => { clearTimeout(t); t = setTimeout(() => fn(...a), ms); };
|
||
}
|
||
|
||
function escapeHtml(str) {
|
||
const d = document.createElement('div');
|
||
d.textContent = str || '';
|
||
return d.innerHTML;
|
||
}
|
||
|
||
// --- Dark Mode ---
|
||
function setupDarkMode() {
|
||
const saved = localStorage.getItem('vendoo-theme');
|
||
if (window.VendooTheme) {
|
||
window.VendooTheme.setTheme(saved === 'light' || saved === 'dark' || saved === 'contrast' ? saved : 'system', { persist: false });
|
||
} else if (saved === 'dark' || (!saved && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||
document.documentElement.setAttribute('data-theme', 'dark');
|
||
}
|
||
updateThemeIcon();
|
||
|
||
document.getElementById('theme-toggle').addEventListener('click', () => {
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||
const nextTheme = isDark ? 'light' : 'dark';
|
||
if (window.VendooTheme) window.VendooTheme.setTheme(nextTheme);
|
||
else {
|
||
document.documentElement.setAttribute('data-theme', nextTheme);
|
||
localStorage.setItem('vendoo-theme', nextTheme);
|
||
}
|
||
updateThemeIcon();
|
||
});
|
||
}
|
||
|
||
function updateThemeIcon() {
|
||
const btn = document.getElementById('theme-toggle');
|
||
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
|
||
btn.title = isDark ? 'Light Mode' : 'Dark Mode';
|
||
btn.innerHTML = isDark
|
||
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>'
|
||
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>';
|
||
}
|
||
|
||
// --- Dashboard & Studio Flow ---
|
||
const STUDIO_STAGES = [
|
||
{ key: 'ready', label: 'Bereit', state: 'Prüfbereit', tone: 'success' },
|
||
{ key: 'scheduled', label: 'Geplant', state: 'Geplant', tone: 'warning' },
|
||
{ key: 'published', label: 'Veröffentlicht', state: 'Live', tone: 'success' },
|
||
{ key: 'sold', label: 'Verkauft', state: 'Verkauft', tone: 'success' },
|
||
];
|
||
|
||
let studioWorkflowData = null;
|
||
let selectedStudioListingId = null;
|
||
let todayFilter = 'all';
|
||
let dashboardAnalyticsData = null;
|
||
let dashboardResizeObserver = null;
|
||
let dashboardResizeTimer = null;
|
||
let dashboardLastChartWidth = 0;
|
||
|
||
function formatCurrency(value) {
|
||
if (value === null || value === undefined || value === '') return '';
|
||
return new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(Number(value) || 0);
|
||
}
|
||
|
||
function platformLabel(platform) {
|
||
return ({ vinted: 'Vinted', 'ebay-ka': 'Kleinanzeigen', 'ebay-de': 'eBay', etsy: 'Etsy', klekt: 'KLEKT' })[platform] || platform || '—';
|
||
}
|
||
|
||
function photoUrl(photo) {
|
||
if (!photo) return '';
|
||
return `/uploads/${String(photo).split('/').map(encodeURIComponent).join('/')}`;
|
||
}
|
||
|
||
function relativeTime(value) {
|
||
if (!value) return '';
|
||
const date = new Date(value);
|
||
if (Number.isNaN(date.getTime())) return '';
|
||
const diff = Math.max(0, Date.now() - date.getTime());
|
||
const minutes = Math.floor(diff / 60000);
|
||
if (minutes < 1) return 'Gerade eben';
|
||
if (minutes < 60) return `vor ${minutes} Min.`;
|
||
const hours = Math.floor(minutes / 60);
|
||
if (hours < 24) return `vor ${hours} Std.`;
|
||
const days = Math.floor(hours / 24);
|
||
if (days < 7) return `vor ${days} Tag${days === 1 ? '' : 'en'}`;
|
||
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
|
||
}
|
||
|
||
function activityGlyph(type) {
|
||
return ({ sold: '✓', published: '↗', failed: '!', scheduled: '◷', copied: '⧉', pending: '·', created: '+' })[type] || '·';
|
||
}
|
||
|
||
function setupAnalyticsDashboard() {
|
||
document.getElementById('dashboard-period')?.addEventListener('change', loadAnalyticsDashboard);
|
||
document.getElementById('dashboard-refresh')?.addEventListener('click', loadAnalyticsDashboard);
|
||
|
||
const dashboardPage = document.getElementById('dashboard');
|
||
const redrawDashboard = () => {
|
||
if (!dashboardAnalyticsData || !dashboardPage?.classList.contains('active')) return;
|
||
clearTimeout(dashboardResizeTimer);
|
||
dashboardResizeTimer = setTimeout(() => {
|
||
const width = document.getElementById('dashboard-line-chart')?.clientWidth || 0;
|
||
if (Math.abs(width - dashboardLastChartWidth) < 12) return;
|
||
dashboardLastChartWidth = width;
|
||
renderDashboardLineChart(dashboardAnalyticsData.timeline || []);
|
||
renderDashboardBars(dashboardAnalyticsData.platformRevenue || []);
|
||
renderDashboardCategories(dashboardAnalyticsData.categoryRevenue || []);
|
||
}, 90);
|
||
};
|
||
if ('ResizeObserver' in window && dashboardPage) {
|
||
dashboardResizeObserver?.disconnect();
|
||
dashboardResizeObserver = new ResizeObserver(redrawDashboard);
|
||
dashboardResizeObserver.observe(dashboardPage);
|
||
} else {
|
||
window.addEventListener('resize', redrawDashboard, { passive: true });
|
||
}
|
||
|
||
document.querySelectorAll('[data-dashboard-action]').forEach(button => button.addEventListener('click', async () => {
|
||
const action = button.dataset.dashboardAction;
|
||
if (action === 'new') return switchToTab('generator');
|
||
if (action === 'qr') {
|
||
switchToTab('generator');
|
||
setTimeout(() => document.getElementById('generator-mobile-upload')?.click(), 160);
|
||
return;
|
||
}
|
||
if (action === 'publish') return switchToTab('publish');
|
||
if (action === 'template') return switchToTab('templates');
|
||
if (action === 'photos') {
|
||
try {
|
||
const listings = await api('GET', '/api/listings');
|
||
const photos = listings.flatMap(item => item.photos || []).slice(0, 60);
|
||
await downloadPublishPhotos(photos);
|
||
} catch (error) { toast(`Fotos konnten nicht geladen werden: ${error.message}`); }
|
||
}
|
||
}));
|
||
}
|
||
|
||
function setupTodayFlow() {
|
||
document.querySelectorAll('[data-today-filter]').forEach(button => button.addEventListener('click', () => {
|
||
todayFilter = button.dataset.todayFilter || 'all';
|
||
document.querySelectorAll('.today-filter-tabs [data-today-filter]').forEach(tab => tab.classList.toggle('active', tab.dataset.todayFilter === todayFilter));
|
||
renderTodayReady(studioWorkflowData);
|
||
if (todayFilter === 'attention') document.querySelector('.attention-panel')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
}));
|
||
document.getElementById('today-sort')?.addEventListener('change', () => renderTodayReady(studioWorkflowData));
|
||
}
|
||
|
||
function dashboardSetText(id, value) {
|
||
const element = document.getElementById(id);
|
||
if (element) element.textContent = value;
|
||
}
|
||
|
||
function renderSparkline(id, values, tone = 'brand') {
|
||
const svg = document.getElementById(id);
|
||
if (!svg) return;
|
||
const raw = (values || []).map(Number).filter(Number.isFinite);
|
||
const nums = raw.length > 1 ? raw : [raw[0] || 0, raw[0] || 0];
|
||
const max = Math.max(...nums, 1);
|
||
const min = Math.min(...nums, 0);
|
||
const range = Math.max(1, max - min);
|
||
const points = nums.map((value, index) => `${(index / Math.max(1, nums.length - 1)) * 96 + 2},${24 - ((value - min) / range) * 18}`).join(' ');
|
||
const last = points.split(' ').at(-1)?.split(',') || ['98', '14'];
|
||
svg.setAttribute('viewBox', '0 0 100 28');
|
||
svg.setAttribute('preserveAspectRatio', 'none');
|
||
svg.innerHTML = `<polyline points="${points}" fill="none" stroke="currentColor" stroke-width="2" vector-effect="non-scaling-stroke" stroke-linecap="round" stroke-linejoin="round"/><circle cx="${last[0]}" cy="${last[1]}" r="2.2" fill="currentColor"/>`;
|
||
svg.dataset.tone = tone;
|
||
}
|
||
|
||
function renderDashboardLineChart(timeline) {
|
||
const container = document.getElementById('dashboard-line-chart');
|
||
if (!container) return;
|
||
const values = timeline.map(item => Number(item.revenue) || 0);
|
||
const max = Math.max(...values, 1);
|
||
const measuredWidth = Math.round(container.getBoundingClientRect().width || container.clientWidth || 720);
|
||
const width = Math.max(320, measuredWidth);
|
||
const height = width < 520 ? 220 : 250;
|
||
const left = width < 520 ? 38 : 48;
|
||
const right = 14;
|
||
const top = 16;
|
||
const bottom = 34;
|
||
const chartW = width - left - right;
|
||
const chartH = height - top - bottom;
|
||
const points = values.map((value, index) => {
|
||
const x = left + (index / Math.max(1, values.length - 1)) * chartW;
|
||
const y = top + chartH - (value / max) * chartH;
|
||
return { x, y, value, item: timeline[index] };
|
||
});
|
||
const line = points.map(point => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(' ');
|
||
const area = `${left},${top + chartH} ${line} ${left + chartW},${top + chartH}`;
|
||
const yGrid = [0, .25, .5, .75, 1].map(part => {
|
||
const y = top + chartH - part * chartH;
|
||
return `<line x1="${left}" y1="${y}" x2="${left + chartW}" y2="${y}"/><text x="${left - 7}" y="${y + 4}">${Math.round(max * part).toLocaleString('de-DE')} €</text>`;
|
||
}).join('');
|
||
const step = Math.max(1, Math.floor(timeline.length / 5));
|
||
const labels = timeline.map((item, index) => index % step === 0 || index === timeline.length - 1
|
||
? `<text x="${points[index].x}" y="${height - 8}" text-anchor="middle">${new Date(item.date).toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })}</text>` : '').join('');
|
||
container.innerHTML = `<svg viewBox="0 0 ${width} ${height}" preserveAspectRatio="xMidYMid meet"><g class="chart-grid">${yGrid}${labels}</g><polygon class="chart-area" points="${area}"/><polyline class="chart-line" points="${line}"/>${points.map(point => `<circle class="chart-point" cx="${point.x}" cy="${point.y}" r="4" tabindex="0"><title>${new Date(point.item.date).toLocaleDateString('de-DE')}: ${formatCurrency(point.value)}</title></circle>`).join('')}</svg>`;
|
||
dashboardLastChartWidth = measuredWidth;
|
||
}
|
||
|
||
function renderDashboardBars(items) {
|
||
const container = document.getElementById('dashboard-platform-chart');
|
||
if (!container) return;
|
||
const data = items.length ? items.slice(0, 5) : [{ platform: 'Keine Verkäufe', revenue: 0 }];
|
||
const max = Math.max(...data.map(item => Number(item.revenue) || 0), 1);
|
||
container.innerHTML = data.map(item => `<div class="dashboard-bar-item" title="${escapeHtml(platformLabel(item.platform))}: ${escapeHtml(formatCurrency(item.revenue))}"><strong>${escapeHtml(formatCurrency(item.revenue))}</strong><div><i style="height:${Math.max(5, (Number(item.revenue) / max) * 100)}%"></i></div><span>${escapeHtml(platformLabel(item.platform))}</span></div>`).join('');
|
||
}
|
||
|
||
function renderDashboardCategories(items) {
|
||
const container = document.getElementById('dashboard-category-chart');
|
||
if (!container) return;
|
||
const data = items.length ? items : [{ category: 'Keine Verkäufe', revenue: 1 }];
|
||
const total = data.reduce((sum, item) => sum + (Number(item.revenue) || 0), 0) || 1;
|
||
let cursor = 0;
|
||
const colors = ['#49a66f', '#3b82dc', '#8054b3', '#e87334', '#e4ab2f', '#b9b6b0'];
|
||
const stops = data.map((item, index) => {
|
||
const start = cursor;
|
||
cursor += ((Number(item.revenue) || 0) / total) * 100;
|
||
return `${colors[index % colors.length]} ${start}% ${cursor}%`;
|
||
}).join(',');
|
||
container.innerHTML = `<div class="dashboard-donut" style="background:conic-gradient(${stops})"><div><strong>${escapeHtml(formatCurrency(total))}</strong><span>Gesamt</span></div></div><div class="dashboard-category-legend">${data.map((item, index) => `<div><i style="background:${colors[index % colors.length]}"></i><span>${escapeHtml(item.category)}</span><strong>${Math.round((Number(item.revenue) || 0) / total * 1000) / 10}%</strong></div>`).join('')}</div>`;
|
||
}
|
||
|
||
function renderDashboardFocus(focus) {
|
||
const container = document.getElementById('dashboard-focus');
|
||
if (!container) return;
|
||
const items = [
|
||
['ready', focus.ready, 'Artikel bereit zum Veröffentlichen', 'history'],
|
||
['price', focus.priceChecks, 'Preisaktualisierungen empfohlen', 'history'],
|
||
['failed', focus.failedPublishes, 'fehlgeschlagene Publish-Versuche', 'publish'],
|
||
['uploads', focus.mobileUploads, 'neue Handy-Foto-Uploads', 'generator'],
|
||
['locations', focus.activeLocations, 'aktive Lagerorte', 'inventory'],
|
||
];
|
||
container.innerHTML = items.map(([tone, count, label, tab]) => `<button class="dashboard-focus-item tone-${tone}" type="button" data-focus-tab="${tab}"><strong>${Number(count) || 0}</strong><span>${escapeHtml(label)}</span><em>Anzeigen</em></button>`).join('');
|
||
container.querySelectorAll('[data-focus-tab]').forEach(button => button.addEventListener('click', () => switchToTab(button.dataset.focusTab)));
|
||
}
|
||
|
||
function renderDashboardTopSellers(items) {
|
||
const body = document.getElementById('dashboard-top-sellers');
|
||
if (!body) return;
|
||
body.innerHTML = items.length ? items.map(item => `<tr data-dashboard-listing="${item.id}"><td><div class="dashboard-product">${item.photo ? `<img src="${escapeHtml(photoUrl(item.photo))}" alt="">` : '<span></span>'}<div><strong>${escapeHtml(item.title || 'Ohne Titel')}</strong><small>${escapeHtml([item.color, item.size].filter(Boolean).join(' / '))}</small></div></div></td><td>${escapeHtml(item.sku || '')}</td><td>${escapeHtml(platformLabel(item.platform))}</td><td>${escapeHtml(formatCurrency(item.revenue))}</td><td>${item.sales || 1}</td><td><span class="dashboard-status">Verkauft</span></td></tr>`).join('') : '<tr><td colspan="6" class="dashboard-empty">Noch keine Verkäufe vorhanden.</td></tr>';
|
||
body.querySelectorAll('[data-dashboard-listing]').forEach(row => row.addEventListener('click', () => showDetail(Number(row.dataset.dashboardListing))));
|
||
}
|
||
|
||
function renderDashboardFeed(id, items, isNotification = false) {
|
||
const container = document.getElementById(id);
|
||
if (!container) return;
|
||
if (!items?.length) { container.innerHTML = '<div class="dashboard-empty">Keine Einträge</div>'; return; }
|
||
container.innerHTML = items.slice(0, 6).map(item => `<button type="button" data-feed-listing="${item.listingId || ''}"><span class="dashboard-feed-icon tone-${item.severity || item.type || 'info'}">${isNotification ? '!' : activityGlyph(item.type)}</span><span><strong>${escapeHtml(item.text || item.message || item.title || 'Aktivität')}</strong><small>${escapeHtml(relativeTime(item.at || item.createdAt))}</small></span></button>`).join('');
|
||
container.querySelectorAll('[data-feed-listing]').forEach(button => button.addEventListener('click', () => { const idValue = Number(button.dataset.feedListing); if (idValue) showDetail(idValue); }));
|
||
}
|
||
|
||
async function loadAnalyticsDashboard() {
|
||
const period = Number(document.getElementById('dashboard-period')?.value || 30);
|
||
const refresh = document.getElementById('dashboard-refresh');
|
||
refresh?.classList.add('is-loading');
|
||
try {
|
||
const data = await api('GET', `/api/dashboard/analytics?days=${period}`);
|
||
dashboardAnalyticsData = data;
|
||
dashboardSetText('dash-revenue-today', formatCurrency(data.kpis.revenueToday));
|
||
dashboardSetText('dash-sales-week', String(data.kpis.sales7Days));
|
||
dashboardSetText('dash-active-listings', String(data.kpis.activeListings));
|
||
dashboardSetText('dash-drafts', String(data.kpis.drafts));
|
||
dashboardSetText('dash-inventory-value', formatCurrency(data.kpis.inventoryValue));
|
||
dashboardSetText('dash-publish-rate', `${data.kpis.publishSuccessRate.toLocaleString('de-DE')}%`);
|
||
dashboardSetText('dash-period-revenue', formatCurrency(data.timeline.reduce((sum, item) => sum + Number(item.revenue || 0), 0)));
|
||
dashboardSetText('dash-period-label', `Letzte ${data.periodDays} Tage`);
|
||
dashboardSetText('dash-platform-total', formatCurrency(data.platformRevenue.reduce((sum, item) => sum + Number(item.revenue || 0), 0)));
|
||
const timelineValues = data.timeline.map(item => item.revenue);
|
||
renderSparkline('spark-revenue', timelineValues, 'green');
|
||
renderSparkline('spark-sales', data.timeline.map(item => item.sales), 'blue');
|
||
renderSparkline('spark-active', timelineValues.map((_, index) => Math.max(0, data.kpis.activeListings - data.timeline.length + index)), 'purple');
|
||
renderSparkline('spark-drafts', timelineValues.map((_, index) => Math.max(0, data.kpis.drafts + Math.sin(index) * 2)), 'orange');
|
||
renderSparkline('spark-inventory', timelineValues.map((value, index) => data.kpis.inventoryValue * .9 + index * (data.kpis.inventoryValue * .1 / Math.max(1, timelineValues.length))), 'green');
|
||
renderSparkline('spark-publish', timelineValues.map((_, index) => Math.max(0, data.kpis.publishSuccessRate - 4 + index / 3)), 'blue');
|
||
renderDashboardLineChart(data.timeline);
|
||
renderDashboardBars(data.platformRevenue);
|
||
renderDashboardCategories(data.categoryRevenue);
|
||
renderDashboardFocus(data.focus);
|
||
renderDashboardTopSellers(data.topSellers);
|
||
renderDashboardFeed('dashboard-activity', data.activity);
|
||
renderDashboardFeed('dashboard-notifications', data.notifications, true);
|
||
dashboardSetText('dashboard-quality-value', `${data.kpis.quality}%`);
|
||
dashboardSetText('dashboard-quality-label', data.kpis.quality >= 85 ? 'Sehr hoch' : data.kpis.quality >= 65 ? 'Gut, mit Potenzial' : 'Optimierung empfohlen');
|
||
document.getElementById('dashboard-quality-ring')?.style.setProperty('--confidence', `${data.kpis.quality}%`);
|
||
dashboardSetText('dashboard-system-state', data.system.healthy ? 'Alle Systeme sind betriebsbereit' : 'Prüfung erforderlich');
|
||
dashboardSetText('dashboard-system-copy', `${data.system.queuePending} Queue-Einträge ausstehend · ${data.system.scheduled} Veröffentlichungen geplant.`);
|
||
} catch (error) {
|
||
toast(`Dashboard konnte nicht geladen werden: ${error.message}`);
|
||
} finally { refresh?.classList.remove('is-loading'); }
|
||
}
|
||
|
||
function getStudioItem(id) {
|
||
if (!studioWorkflowData?.stages) return null;
|
||
for (const stage of Object.values(studioWorkflowData.stages)) {
|
||
const found = stage.items?.find(item => item.id === id);
|
||
if (found) return found;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function currentTodayItems(data) {
|
||
if (!data?.stages) return [];
|
||
const key = todayFilter === 'all' ? 'ready' : todayFilter;
|
||
if (key === 'attention') return [...(data.stages.captured?.items || []), ...(data.stages.ai_review?.items || [])];
|
||
return data.stages[key]?.items || [];
|
||
}
|
||
|
||
function renderTodaySummary(data) {
|
||
const set = (id, value) => { const element = document.getElementById(id); if (element) element.textContent = String(value || 0); };
|
||
set('today-count-ready', data.stages?.ready?.count);
|
||
set('today-count-scheduled', data.stages?.scheduled?.count);
|
||
set('today-count-published', data.stages?.published?.count);
|
||
set('today-count-sold', data.stages?.sold?.count);
|
||
set('today-count-attention', data.attentionCount);
|
||
set('today-ready-total', data.stages?.ready?.count);
|
||
set('today-scheduled-total', data.stages?.scheduled?.count);
|
||
}
|
||
|
||
function renderTodayReady(data) {
|
||
const container = document.getElementById('today-ready-grid');
|
||
if (!container || !data) return;
|
||
const labels = { all: 'Bereit für den nächsten Schritt', ready: 'Bereit für den nächsten Schritt', scheduled: 'Geplante Artikel', published: 'Veröffentlichte Artikel', sold: 'Verkaufte Artikel', attention: 'Artikel mit Prüfbedarf' };
|
||
const heading = container.closest('.work-panel')?.querySelector('h3');
|
||
if (heading) heading.textContent = labels[todayFilter] || labels.all;
|
||
let items = [...currentTodayItems(data)];
|
||
const sort = document.getElementById('today-sort')?.value || 'priority';
|
||
if (sort === 'newest') items.sort((a, b) => String(b.updatedAt || b.createdAt).localeCompare(String(a.updatedAt || a.createdAt)));
|
||
if (sort === 'price') items.sort((a, b) => (Number(b.price) || 0) - (Number(a.price) || 0));
|
||
items = items.slice(0, 4);
|
||
if (!items.length) { container.innerHTML = '<div class="empty-state">Für diesen Filter sind keine Artikel vorhanden.</div>'; return; }
|
||
container.innerHTML = items.map(item => `<button class="today-product-card${selectedStudioListingId === item.id ? ' selected' : ''}" type="button" data-studio-listing="${item.id}"><div class="today-product-media">${item.photos?.[0] ? `<img src="${escapeHtml(photoUrl(item.photos[0]))}" alt="${escapeHtml(item.title || 'Produktbild')}" decoding="async" draggable="false">` : '<span class="today-image-placeholder">Kein Bild</span>'}<b>${escapeHtml(platformLabel(item.platform))}</b></div><div class="today-product-copy"><strong>${escapeHtml(item.title)}</strong><span>${escapeHtml(item.size || item.color || item.sku || '')}</span><div><em>${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}</em><small>${Math.max(0, item.completeness || 0)}%</small></div></div></button>`).join('');
|
||
container.querySelectorAll('.today-product-media img').forEach(image => image.addEventListener('error', () => {
|
||
image.hidden = true;
|
||
const placeholder = document.createElement('span');
|
||
placeholder.className = 'today-image-placeholder';
|
||
placeholder.textContent = 'Bild nicht verfügbar';
|
||
image.parentElement?.prepend(placeholder);
|
||
}, { once: true }));
|
||
container.querySelectorAll('[data-studio-listing]').forEach(card => card.addEventListener('click', () => selectStudioListing(Number(card.dataset.studioListing))));
|
||
}
|
||
|
||
function renderTodayScheduled(data) {
|
||
const body = document.getElementById('today-scheduled-list');
|
||
if (!body) return;
|
||
const items = data.stages?.scheduled?.items?.slice(0, 4) || [];
|
||
body.innerHTML = items.length ? items.map(item => `<tr><td><button type="button" data-scheduled-id="${item.id}"><span>${item.photos?.[0] ? `<img src="${escapeHtml(photoUrl(item.photos[0]))}" alt="">` : ''}</span><div><strong>${escapeHtml(item.title)}</strong><small>${escapeHtml(item.size || item.sku || '')}</small></div></button></td><td>${escapeHtml(platformLabel(item.platform))}</td><td>${item.scheduledAt ? new Date(item.scheduledAt.replace(' ', 'T')).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—'}</td><td><span class="today-status">Geplant</span></td><td><button class="vd-button compact" type="button" data-scheduled-id="${item.id}">Vorschau</button></td></tr>`).join('') : '<tr><td colspan="5" class="dashboard-empty">Keine geplanten Veröffentlichungen.</td></tr>';
|
||
body.querySelectorAll('[data-scheduled-id]').forEach(button => button.addEventListener('click', () => selectStudioListing(Number(button.dataset.scheduledId))));
|
||
}
|
||
|
||
function renderStudioAttention(data) {
|
||
const list = document.getElementById('studio-attention');
|
||
const count = document.getElementById('attention-count');
|
||
if (count) count.textContent = String(data.attentionCount || 0);
|
||
if (!list) return;
|
||
if (!data.attention?.length) { list.innerHTML = '<div class="empty-state">Alles erledigt – aktuell ist keine Aktion nötig.</div>'; return; }
|
||
const grouped = new Map();
|
||
for (const item of data.attention) {
|
||
const current = grouped.get(item.type) || { ...item, count: 0 };
|
||
current.count += 1;
|
||
grouped.set(item.type, current);
|
||
}
|
||
list.innerHTML = [...grouped.values()].slice(0, 5).map(item => `<button class="attention-item" type="button" data-attention-id="${item.listingId}" data-attention-type="${escapeHtml(item.type)}"><span class="attention-symbol">${item.severity === 'danger' ? '!' : '○'}</span><span class="attention-copy"><strong>${escapeHtml(item.message)}</strong><small>${escapeHtml(item.action)}</small></span><b>${item.count}</b></button>`).join('');
|
||
list.querySelectorAll('[data-attention-id]').forEach(button => button.addEventListener('click', () => {
|
||
const id = Number(button.dataset.attentionId);
|
||
if (button.dataset.attentionType === 'publish_failed') switchToTab('publish');
|
||
else showDetail(id);
|
||
}));
|
||
}
|
||
|
||
function renderStudioActivity(data) {
|
||
const recent = document.getElementById('studio-activity');
|
||
const completed = document.getElementById('studio-completed');
|
||
const render = (container, items) => {
|
||
if (!container) return;
|
||
container.innerHTML = items.length ? items.map(item => `<button class="activity-item" type="button" data-activity-listing="${item.listingId || ''}"><span class="activity-icon">${activityGlyph(item.type)}</span><span class="activity-copy">${escapeHtml(item.text)}</span><span class="activity-time">${escapeHtml(relativeTime(item.at))}</span></button>`).join('') : '<div class="empty-state">Noch keine Aktivität</div>';
|
||
container.querySelectorAll('[data-activity-listing]').forEach(button => button.addEventListener('click', () => { const id = Number(button.dataset.activityListing); if (id) selectStudioListing(id); }));
|
||
};
|
||
const completeTypes = new Set(['published', 'sold']);
|
||
render(recent, data.activity.filter(item => !completeTypes.has(item.type)).slice(0, 4));
|
||
render(completed, data.activity.filter(item => completeTypes.has(item.type)).slice(0, 4));
|
||
}
|
||
|
||
function selectStudioListing(id) {
|
||
const item = getStudioItem(id);
|
||
if (!item) return;
|
||
selectedStudioListingId = id;
|
||
document.querySelectorAll('[data-studio-listing]').forEach(card => card.classList.toggle('selected', Number(card.dataset.studioListing) === id));
|
||
renderStudioInspector(item);
|
||
}
|
||
|
||
function renderStudioInspector(item) {
|
||
const empty = document.getElementById('studio-inspector-empty');
|
||
const content = document.getElementById('studio-inspector-content');
|
||
if (!content || !empty) return;
|
||
empty.classList.add('hidden');
|
||
content.classList.remove('hidden');
|
||
const photos = item.photos || [];
|
||
const hero = photos[0] ? `<img src="${escapeHtml(photoUrl(photos[0]))}" alt="${escapeHtml(item.title)}">` : '<div class="flow-card-placeholder">Kein Bild</div>';
|
||
const confidence = Math.max(0, Math.min(100, item.completeness || 0));
|
||
const low = Math.max(0, (Number(item.price) || 0) * .92);
|
||
const high = Math.max(low, (Number(item.price) || 0) * 1.08);
|
||
content.innerHTML = `<div class="inspector-title-row"><div><h3>${escapeHtml(item.title)}</h3><div class="inspector-sku">SKU ${escapeHtml(item.sku || '')}</div></div><span class="flow-state success">${item.status === 'sold' ? 'Verkauft' : 'Bereit'}</span></div><div class="inspector-hero">${hero}<span class="inspector-photo-count">▧ ${photos.length}</span></div>${photos.length > 1 ? `<div class="inspector-thumbs">${photos.slice(0, 5).map(photo => `<img src="${escapeHtml(photoUrl(photo))}" alt="">`).join('')}${photos.length > 5 ? `<span>+${photos.length - 5}</span>` : ''}</div>` : ''}<div class="inspector-price-row"><span>Preis</span><strong>${escapeHtml(formatCurrency(item.price) || 'Preis fehlt')}</strong></div><section class="inspector-card"><h4>AI-Konfidenz</h4><div class="confidence-row"><div class="confidence-ring" style="--confidence:${confidence}%"><span>${confidence}%</span></div><div class="confidence-copy"><strong>${confidence >= 85 ? 'Sehr hoch' : confidence >= 60 ? 'Prüfung empfohlen' : 'Unvollständig'}</strong><span>${confidence >= 85 ? 'Alle wichtigen Attribute wurden erkannt.' : 'Einige Angaben sollten ergänzt werden.'}</span></div></div></section><section class="inspector-card"><h4>Wichtige Attribute</h4><dl class="attribute-list"><dt>Marke</dt><dd>${escapeHtml(item.brand || '—')}</dd><dt>Kategorie</dt><dd>${escapeHtml(item.category || '—')}</dd><dt>Farbe</dt><dd>${escapeHtml(item.color || '—')}</dd><dt>Größe</dt><dd>${escapeHtml(item.size || '—')}</dd><dt>Zustand</dt><dd>${escapeHtml(item.condition || '—')}</dd><dt>Lagerort</dt><dd>${escapeHtml(item.storageLocation || 'Nicht zugewiesen')}</dd></dl></section>${item.price ? `<section class="inspector-card"><h4>Preisempfehlung</h4><div class="price-range">${escapeHtml(formatCurrency(low))} – ${escapeHtml(formatCurrency(high))}</div><div class="recommended-price"><span>Empfohlener Preis</span><strong>${escapeHtml(formatCurrency(item.price))}</strong></div></section>` : ''}<div class="inspector-actions"><button class="inspector-action primary" type="button" data-inspector-action="review">Prüfen</button><button class="inspector-action" type="button" data-inspector-action="publish">Veröffentlichen</button><button class="inspector-action" type="button" data-inspector-action="inventory">Lagerort setzen</button></div>`;
|
||
content.querySelector('[data-inspector-action="review"]')?.addEventListener('click', () => showDetail(item.id));
|
||
content.querySelector('[data-inspector-action="publish"]')?.addEventListener('click', () => { publishState.selectedId = item.id; switchToTab('publish'); });
|
||
content.querySelector('[data-inspector-action="inventory"]')?.addEventListener('click', () => switchToTab('inventory'));
|
||
}
|
||
|
||
async function loadToday() {
|
||
const ready = document.getElementById('today-ready-grid');
|
||
if (ready) ready.innerHTML = '<div class="empty-state">Studio Flow wird geladen…</div>';
|
||
try {
|
||
const data = await api('GET', '/api/dashboard/workflow?limit=12');
|
||
studioWorkflowData = data;
|
||
renderTodaySummary(data);
|
||
renderTodayReady(data);
|
||
renderTodayScheduled(data);
|
||
renderStudioAttention(data);
|
||
renderStudioActivity(data);
|
||
const first = data.stages?.ready?.items?.[0] || data.stages?.scheduled?.items?.[0] || data.stages?.published?.items?.[0] || data.stages?.sold?.items?.[0];
|
||
if (first && !selectedStudioListingId) selectStudioListing(first.id);
|
||
else if (selectedStudioListingId) { const selected = getStudioItem(selectedStudioListingId); if (selected) renderStudioInspector(selected); }
|
||
} catch (error) {
|
||
if (ready) ready.innerHTML = `<div class="empty-state">Studio Flow konnte nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
|
||
// --- Advanced Image Editor ---
|
||
function createPhotoEditorOperations() {
|
||
return {
|
||
rotation: 0,
|
||
straighten: 0,
|
||
flip_h: false,
|
||
flip_v: false,
|
||
crop: null,
|
||
exposure: 0,
|
||
brightness: 0,
|
||
contrast: 0,
|
||
highlights: 0,
|
||
shadows: 0,
|
||
temperature: 0,
|
||
saturation: 0,
|
||
sharpness: 0,
|
||
remove_background: false,
|
||
background_sensitivity: 45,
|
||
watermark: { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 },
|
||
resize: { width: 0, height: 0 },
|
||
format: 'jpeg',
|
||
quality: 90,
|
||
};
|
||
}
|
||
|
||
let photoEditorState = {
|
||
context: 'generator', platform: '', index: -1,
|
||
currentPath: '', rootPath: '', sourceImage: null, rootImage: null,
|
||
operations: createPhotoEditorOperations(), history: [], historyIndex: -1,
|
||
versions: [], compare: false, cropping: false, cropRect: null,
|
||
cropStart: null, cropEnd: null, _dragOrigin: null, _dragging: false,
|
||
};
|
||
|
||
function cloneEditorOperations(value = photoEditorState.operations) {
|
||
return JSON.parse(JSON.stringify(value));
|
||
}
|
||
|
||
function getPhotoEditorPhotos() {
|
||
if (photoEditorState.context === 'flux') return state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : [];
|
||
if (photoEditorState.context === 'media') return state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : [];
|
||
return photoEditorState.context === 'detail' ? (detailEditorState?.photos || []) : state.photos;
|
||
}
|
||
|
||
async function commitPhotoEditorResult(filename) {
|
||
if (!filename) return;
|
||
if (photoEditorState.context === 'flux') {
|
||
const current = state.fluxStudioCurrent || {};
|
||
const item = await api('POST', '/api/flux-studio/images', {
|
||
image_path: filename,
|
||
prompt: current.prompt || 'Nachbearbeitete FLUX-Ausgabe',
|
||
width: current.width,
|
||
height: current.height,
|
||
seed: current.seed,
|
||
steps: current.steps,
|
||
style: current.style,
|
||
final_prompt: current.final_prompt,
|
||
profile: current.profile,
|
||
duration_ms: current.duration_ms,
|
||
edited_from: current.id || null,
|
||
favorite: current.favorite,
|
||
job_id: current.job_id,
|
||
variant_index: current.variant_index,
|
||
prompt_mode: current.prompt_mode,
|
||
parameters: current.parameters,
|
||
});
|
||
renderFluxStudioPreview(item);
|
||
await loadFluxStudioHistory();
|
||
return;
|
||
}
|
||
if (photoEditorState.context === 'media') {
|
||
state.mediaCurrent = { ...(state.mediaCurrent || {}), image_path: filename, public_url: photoUrl(filename), source: 'edited', sources: ['edited'] };
|
||
await loadMediaLibrary();
|
||
return;
|
||
}
|
||
const photos = getPhotoEditorPhotos();
|
||
if (!photos[photoEditorState.index]) return;
|
||
photos[photoEditorState.index] = filename;
|
||
if (photoEditorState.context === 'detail') renderDetailPhotos();
|
||
else renderPhotoPreview();
|
||
}
|
||
|
||
function getPhotoEditorPlatform() {
|
||
if (photoEditorState.context === 'flux') return 'ai-generated';
|
||
if (photoEditorState.context === 'media') return state.mediaCurrent?.source || 'media-library';
|
||
if (photoEditorState.context === 'detail') {
|
||
return document.getElementById('detail-platform')?.value || detailEditorState?.listing?.platform || 'allgemein';
|
||
}
|
||
return state.platform || 'allgemein';
|
||
}
|
||
|
||
function setupPhotoEditor() {
|
||
document.getElementById('photo-editor-close').addEventListener('click', closePhotoEditor);
|
||
document.getElementById('pe-cancel').addEventListener('click', closePhotoEditor);
|
||
document.getElementById('photo-editor-overlay').addEventListener('click', event => {
|
||
if (event.target === event.currentTarget) closePhotoEditor();
|
||
});
|
||
|
||
document.getElementById('pe-undo').addEventListener('click', () => stepPhotoEditorHistory(-1));
|
||
document.getElementById('pe-redo').addEventListener('click', () => stepPhotoEditorHistory(1));
|
||
document.getElementById('pe-compare').addEventListener('click', togglePhotoEditorCompare);
|
||
document.getElementById('pe-restore-original').addEventListener('click', restorePhotoEditorOriginal);
|
||
|
||
const sliders = {
|
||
straighten: { digits: 1, suffix: '°' },
|
||
exposure: { digits: 1, suffix: '' },
|
||
brightness: { digits: 0, suffix: '' },
|
||
contrast: { digits: 0, suffix: '' },
|
||
highlights: { digits: 0, suffix: '' },
|
||
shadows: { digits: 0, suffix: '' },
|
||
temperature: { digits: 0, suffix: '' },
|
||
saturation: { digits: 0, suffix: '' },
|
||
sharpness: { digits: 0, suffix: '' },
|
||
};
|
||
for (const [property, format] of Object.entries(sliders)) {
|
||
const slider = document.getElementById(`pe-${property}`);
|
||
slider.addEventListener('input', () => {
|
||
photoEditorState.operations[property] = Number(slider.value);
|
||
updatePhotoEditorRangeValue(property, format.digits, format.suffix);
|
||
renderPhotoEditorCanvas();
|
||
});
|
||
slider.addEventListener('change', pushPhotoEditorHistory);
|
||
}
|
||
|
||
document.getElementById('pe-rotate-left').addEventListener('click', () => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 270) % 360;
|
||
}));
|
||
document.getElementById('pe-rotate-right').addEventListener('click', () => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.rotation = (photoEditorState.operations.rotation + 90) % 360;
|
||
}));
|
||
document.getElementById('pe-flip-h').addEventListener('click', () => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.flip_h = !photoEditorState.operations.flip_h;
|
||
}));
|
||
document.getElementById('pe-flip-v').addEventListener('click', () => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.flip_v = !photoEditorState.operations.flip_v;
|
||
}));
|
||
|
||
document.getElementById('pe-reset-adjustments').addEventListener('click', () => mutatePhotoEditor(() => {
|
||
for (const property of ['exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) {
|
||
photoEditorState.operations[property] = 0;
|
||
}
|
||
}));
|
||
|
||
document.getElementById('pe-crop-toggle').addEventListener('click', toggleCropMode);
|
||
document.getElementById('pe-crop-apply').addEventListener('click', applyCropSelection);
|
||
document.getElementById('pe-crop-cancel').addEventListener('click', cancelCropMode);
|
||
document.getElementById('pe-crop-ratio').addEventListener('change', setDefaultCropSelection);
|
||
|
||
document.getElementById('pe-remove-background').addEventListener('change', event => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.remove_background = event.target.checked;
|
||
if (event.target.checked && photoEditorState.operations.format === 'jpeg') photoEditorState.operations.format = 'png';
|
||
}));
|
||
document.getElementById('pe-background-sensitivity').addEventListener('input', event => {
|
||
photoEditorState.operations.background_sensitivity = Number(event.target.value);
|
||
document.getElementById('pe-background-sensitivity-val').textContent = `${event.target.value}%`;
|
||
renderPhotoEditorCanvas();
|
||
});
|
||
document.getElementById('pe-background-sensitivity').addEventListener('change', pushPhotoEditorHistory);
|
||
document.getElementById('pe-watermark-enabled').addEventListener('change', event => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.watermark.enabled = event.target.checked;
|
||
}));
|
||
document.getElementById('pe-watermark-text').addEventListener('input', event => {
|
||
photoEditorState.operations.watermark.text = event.target.value.slice(0, 80);
|
||
renderPhotoEditorCanvas();
|
||
});
|
||
document.getElementById('pe-watermark-text').addEventListener('change', pushPhotoEditorHistory);
|
||
document.getElementById('pe-watermark-position').addEventListener('change', event => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.watermark.position = event.target.value;
|
||
}));
|
||
document.getElementById('pe-watermark-opacity').addEventListener('input', event => {
|
||
photoEditorState.operations.watermark.opacity = Number(event.target.value);
|
||
document.getElementById('pe-watermark-opacity-val').textContent = `${event.target.value}%`;
|
||
renderPhotoEditorCanvas();
|
||
});
|
||
document.getElementById('pe-watermark-opacity').addEventListener('change', pushPhotoEditorHistory);
|
||
|
||
const cropOverlay = document.getElementById('photo-editor-crop-overlay');
|
||
cropOverlay.addEventListener('mousedown', cropMouseDown);
|
||
cropOverlay.addEventListener('mousemove', cropMouseMove);
|
||
cropOverlay.addEventListener('mouseup', cropMouseUp);
|
||
cropOverlay.addEventListener('mouseleave', cropMouseUp);
|
||
cropOverlay.addEventListener('touchstart', event => { event.preventDefault(); cropMouseDown(touchToMouse(event)); }, { passive: false });
|
||
cropOverlay.addEventListener('touchmove', event => { event.preventDefault(); cropMouseMove(touchToMouse(event)); }, { passive: false });
|
||
cropOverlay.addEventListener('touchend', cropMouseUp);
|
||
|
||
document.getElementById('pe-format').addEventListener('change', event => mutatePhotoEditor(() => {
|
||
photoEditorState.operations.format = event.target.value;
|
||
}, false));
|
||
document.getElementById('pe-quality').addEventListener('input', event => {
|
||
photoEditorState.operations.quality = Number(event.target.value);
|
||
document.getElementById('pe-quality-val').textContent = `${event.target.value}%`;
|
||
});
|
||
document.getElementById('pe-quality').addEventListener('change', pushPhotoEditorHistory);
|
||
document.getElementById('pe-target-size').addEventListener('change', updatePhotoEditorResize);
|
||
document.getElementById('pe-target-width').addEventListener('change', updatePhotoEditorResize);
|
||
document.getElementById('pe-target-height').addEventListener('change', updatePhotoEditorResize);
|
||
document.getElementById('pe-apply').addEventListener('click', applyPhotoEdit);
|
||
|
||
window.addEventListener('resize', () => {
|
||
if (!document.getElementById('photo-editor-overlay').classList.contains('hidden')) syncCropOverlay();
|
||
});
|
||
document.addEventListener('keydown', event => {
|
||
const overlay = document.getElementById('photo-editor-overlay');
|
||
if (!overlay || overlay.classList.contains('hidden')) return;
|
||
if (event.key === 'Escape') {
|
||
event.preventDefault();
|
||
closePhotoEditor();
|
||
return;
|
||
}
|
||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z') return;
|
||
event.preventDefault();
|
||
stepPhotoEditorHistory(event.shiftKey ? 1 : -1);
|
||
});
|
||
}
|
||
|
||
function updatePhotoEditorRangeValue(property, digits = 0, suffix = '') {
|
||
const value = Number(photoEditorState.operations[property] || 0);
|
||
const text = digits ? value.toFixed(digits).replace('.', ',') : String(Math.round(value));
|
||
document.getElementById(`pe-${property}-val`).textContent = `${text}${suffix}`;
|
||
}
|
||
|
||
function updatePhotoEditorControls() {
|
||
for (const property of ['straighten', 'exposure', 'brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) {
|
||
const input = document.getElementById(`pe-${property}`);
|
||
input.value = photoEditorState.operations[property];
|
||
}
|
||
updatePhotoEditorRangeValue('straighten', 1, '°');
|
||
updatePhotoEditorRangeValue('exposure', 1);
|
||
for (const property of ['brightness', 'contrast', 'highlights', 'shadows', 'temperature', 'saturation', 'sharpness']) updatePhotoEditorRangeValue(property);
|
||
document.getElementById('pe-format').value = photoEditorState.operations.format;
|
||
document.getElementById('pe-quality').value = photoEditorState.operations.quality;
|
||
document.getElementById('pe-quality-val').textContent = `${photoEditorState.operations.quality}%`;
|
||
document.getElementById('pe-remove-background').checked = Boolean(photoEditorState.operations.remove_background);
|
||
document.getElementById('pe-background-sensitivity').value = Number(photoEditorState.operations.background_sensitivity ?? 45);
|
||
document.getElementById('pe-background-sensitivity-val').textContent = `${Number(photoEditorState.operations.background_sensitivity ?? 45)}%`;
|
||
const watermark = photoEditorState.operations.watermark || { enabled: false, text: 'Vendoo', position: 'bottom-right', opacity: 40 };
|
||
document.getElementById('pe-watermark-enabled').checked = Boolean(watermark.enabled);
|
||
document.getElementById('pe-watermark-text').value = watermark.text || 'Vendoo';
|
||
document.getElementById('pe-watermark-position').value = watermark.position || 'bottom-right';
|
||
document.getElementById('pe-watermark-opacity').value = Number(watermark.opacity || 40);
|
||
document.getElementById('pe-watermark-opacity-val').textContent = `${Number(watermark.opacity || 40)}%`;
|
||
|
||
const { width, height } = photoEditorState.operations.resize || {};
|
||
const target = width && height && width === height && [1200, 1600, 2048].includes(width) ? String(width) : width || height ? 'custom' : 'original';
|
||
document.getElementById('pe-target-size').value = target;
|
||
document.getElementById('pe-custom-size').classList.toggle('hidden', target !== 'custom');
|
||
document.getElementById('pe-target-width').value = target === 'custom' && width ? width : '';
|
||
document.getElementById('pe-target-height').value = target === 'custom' && height ? height : '';
|
||
}
|
||
|
||
function mutatePhotoEditor(mutator, render = true) {
|
||
mutator();
|
||
updatePhotoEditorControls();
|
||
pushPhotoEditorHistory();
|
||
if (render) renderPhotoEditorCanvas();
|
||
}
|
||
|
||
function pushPhotoEditorHistory() {
|
||
const snapshot = cloneEditorOperations();
|
||
const current = photoEditorState.history[photoEditorState.historyIndex];
|
||
if (current && JSON.stringify(current) === JSON.stringify(snapshot)) return;
|
||
photoEditorState.history = photoEditorState.history.slice(0, photoEditorState.historyIndex + 1);
|
||
photoEditorState.history.push(snapshot);
|
||
photoEditorState.historyIndex = photoEditorState.history.length - 1;
|
||
updatePhotoEditorHistoryControls();
|
||
}
|
||
|
||
function stepPhotoEditorHistory(direction) {
|
||
const next = photoEditorState.historyIndex + direction;
|
||
if (next < 0 || next >= photoEditorState.history.length) return;
|
||
photoEditorState.historyIndex = next;
|
||
photoEditorState.operations = cloneEditorOperations(photoEditorState.history[next]);
|
||
cancelCropMode(false);
|
||
updatePhotoEditorControls();
|
||
updatePhotoEditorHistoryControls();
|
||
renderPhotoEditorCanvas();
|
||
}
|
||
|
||
function updatePhotoEditorHistoryControls() {
|
||
document.getElementById('pe-undo').disabled = photoEditorState.historyIndex <= 0;
|
||
document.getElementById('pe-redo').disabled = photoEditorState.historyIndex >= photoEditorState.history.length - 1;
|
||
document.getElementById('pe-history-status').textContent = `Schritt ${Math.max(1, photoEditorState.historyIndex + 1)} von ${Math.max(1, photoEditorState.history.length)}`;
|
||
}
|
||
|
||
function updatePhotoEditorResize() {
|
||
const target = document.getElementById('pe-target-size').value;
|
||
const custom = document.getElementById('pe-custom-size');
|
||
custom.classList.toggle('hidden', target !== 'custom');
|
||
if (target === 'original') photoEditorState.operations.resize = { width: 0, height: 0 };
|
||
else if (target === 'custom') {
|
||
photoEditorState.operations.resize = {
|
||
width: Math.max(0, Number(document.getElementById('pe-target-width').value) || 0),
|
||
height: Math.max(0, Number(document.getElementById('pe-target-height').value) || 0),
|
||
};
|
||
} else {
|
||
const size = Number(target);
|
||
photoEditorState.operations.resize = { width: size, height: size };
|
||
}
|
||
pushPhotoEditorHistory();
|
||
renderPhotoEditorCanvas();
|
||
}
|
||
|
||
function loadEditorImage(path) {
|
||
return new Promise((resolve, reject) => {
|
||
const image = new Image();
|
||
image.crossOrigin = 'anonymous';
|
||
image.onload = () => resolve(image);
|
||
image.onerror = () => reject(new Error('Bildvorschau konnte nicht geladen werden.'));
|
||
image.src = `${photoUrl(path)}${String(photoUrl(path)).includes('?') ? '&' : '?'}editor=${Date.now()}`;
|
||
});
|
||
}
|
||
|
||
async function openPhotoEditor(index, context = 'generator') {
|
||
const photos = context === 'flux' ? (state.fluxStudioCurrent?.image_path ? [state.fluxStudioCurrent.image_path] : []) : context === 'media' ? (state.mediaCurrent?.image_path ? [state.mediaCurrent.image_path] : []) : context === 'detail' ? (detailEditorState?.photos || []) : state.photos;
|
||
const photo = photos[index];
|
||
if (!photo) return;
|
||
|
||
photoEditorState = {
|
||
context,
|
||
platform: context === 'flux' ? 'ai-generated' : context === 'media' ? (state.mediaCurrent?.source || 'media-library') : context === 'detail' ? (detailEditorState?.listing?.platform || '') : state.platform,
|
||
index,
|
||
currentPath: photo,
|
||
rootPath: photo,
|
||
sourceImage: null,
|
||
rootImage: null,
|
||
operations: createPhotoEditorOperations(),
|
||
history: [], historyIndex: -1, versions: [], compare: false,
|
||
cropping: false, cropRect: null, cropStart: null, cropEnd: null,
|
||
_dragOrigin: null, _dragging: false,
|
||
};
|
||
|
||
const overlay = document.getElementById('photo-editor-overlay');
|
||
overlay.classList.remove('hidden');
|
||
overlay.setAttribute('aria-hidden', 'false');
|
||
document.getElementById('pe-image-meta').textContent = 'Versionen werden geladen …';
|
||
document.getElementById('pe-version-list').innerHTML = '<div class="empty-state">Versionen werden geladen …</div>';
|
||
updatePhotoEditorControls();
|
||
updatePhotoEditorHistoryControls();
|
||
setPhotoEditorCompare(false);
|
||
|
||
try {
|
||
const history = await api('GET', `/api/image-editor/versions?path=${encodeURIComponent(photo)}`);
|
||
photoEditorState.rootPath = history.root_path || photo;
|
||
photoEditorState.versions = [history.original, ...(history.versions || [])].filter(Boolean);
|
||
const [sourceImage, rootImage] = await Promise.all([
|
||
loadEditorImage(photo),
|
||
history.root_path && history.root_path !== photo ? loadEditorImage(history.root_path) : loadEditorImage(photo),
|
||
]);
|
||
photoEditorState.sourceImage = sourceImage;
|
||
photoEditorState.rootImage = rootImage;
|
||
resetPhotoEditorSession();
|
||
renderPhotoEditorVersions();
|
||
renderPhotoEditorCanvas();
|
||
} catch (error) {
|
||
toast(error.message || 'Bildeditor konnte nicht geöffnet werden.');
|
||
closePhotoEditor();
|
||
}
|
||
}
|
||
|
||
function resetPhotoEditorSession() {
|
||
const extension = String(photoEditorState.currentPath || '').split('.').pop()?.toLowerCase();
|
||
photoEditorState.operations = createPhotoEditorOperations();
|
||
if (extension === 'png') photoEditorState.operations.format = 'png';
|
||
else if (extension === 'webp') photoEditorState.operations.format = 'webp';
|
||
photoEditorState.history = [cloneEditorOperations()];
|
||
photoEditorState.historyIndex = 0;
|
||
cancelCropMode(false);
|
||
updatePhotoEditorControls();
|
||
updatePhotoEditorHistoryControls();
|
||
}
|
||
|
||
async function loadPhotoEditorVersion(path) {
|
||
if (!path || path === photoEditorState.currentPath) return;
|
||
try {
|
||
document.getElementById('pe-image-meta').textContent = 'Version wird geladen …';
|
||
photoEditorState.sourceImage = await loadEditorImage(path);
|
||
photoEditorState.currentPath = path;
|
||
resetPhotoEditorSession();
|
||
renderPhotoEditorVersions();
|
||
renderPhotoEditorCanvas();
|
||
} catch (error) { toast(error.message); }
|
||
}
|
||
|
||
function formatEditorFileSize(bytes) {
|
||
const size = Number(bytes) || 0;
|
||
if (size < 1024) return `${size} B`;
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(0)} KB`;
|
||
return `${(size / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
|
||
}
|
||
|
||
function renderPhotoEditorVersions() {
|
||
const list = document.getElementById('pe-version-list');
|
||
const versions = photoEditorState.versions || [];
|
||
document.getElementById('pe-version-count').textContent = String(Math.max(0, versions.length - 1));
|
||
if (!versions.length) {
|
||
list.innerHTML = '<div class="empty-state">Noch keine Bearbeitungsversion.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = versions.map((version, index) => {
|
||
const path = version.output_path || version.root_path;
|
||
const isOriginal = version.id === 'original';
|
||
const date = version.created_at ? new Date(version.created_at).toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' }) : '';
|
||
const dimensions = version.width && version.height ? `${version.width} × ${version.height}` : '';
|
||
return `<button class="pe-version-item${path === photoEditorState.currentPath ? ' active' : ''}" type="button" data-version-path="${escapeHtml(path)}"><span>${isOriginal ? 'ORIG' : `V${versions.length - index}`}</span><div><strong>${isOriginal ? 'Unverändertes Original' : 'Bearbeitungsversion'}</strong><small>${escapeHtml([dimensions, formatEditorFileSize(version.file_size), date].filter(Boolean).join(' · '))}</small></div></button>`;
|
||
}).join('');
|
||
list.querySelectorAll('[data-version-path]').forEach(button => button.addEventListener('click', () => loadPhotoEditorVersion(button.dataset.versionPath)));
|
||
}
|
||
|
||
function createPreviewCanvas(image, operations, { applyCrop = true, maxDimension = 1100 } = {}) {
|
||
const scale = Math.min(1, maxDimension / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height));
|
||
const sourceWidth = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
|
||
const sourceHeight = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
|
||
const angle = (operations.rotation + operations.straighten) * Math.PI / 180;
|
||
const cos = Math.abs(Math.cos(angle));
|
||
const sin = Math.abs(Math.sin(angle));
|
||
const outputWidth = Math.max(1, Math.ceil(sourceWidth * cos + sourceHeight * sin));
|
||
const outputHeight = Math.max(1, Math.ceil(sourceWidth * sin + sourceHeight * cos));
|
||
const geometry = document.createElement('canvas');
|
||
geometry.width = outputWidth;
|
||
geometry.height = outputHeight;
|
||
const geometryContext = geometry.getContext('2d', { willReadFrequently: true });
|
||
geometryContext.fillStyle = '#ffffff';
|
||
geometryContext.fillRect(0, 0, outputWidth, outputHeight);
|
||
geometryContext.save();
|
||
geometryContext.translate(outputWidth / 2, outputHeight / 2);
|
||
geometryContext.rotate(angle);
|
||
geometryContext.scale(operations.flip_h ? -1 : 1, operations.flip_v ? -1 : 1);
|
||
geometryContext.drawImage(image, -sourceWidth / 2, -sourceHeight / 2, sourceWidth, sourceHeight);
|
||
geometryContext.restore();
|
||
|
||
let result = geometry;
|
||
if (applyCrop && operations.crop) {
|
||
const left = Math.max(0, Math.min(geometry.width - 1, Math.round(operations.crop.x * geometry.width)));
|
||
const top = Math.max(0, Math.min(geometry.height - 1, Math.round(operations.crop.y * geometry.height)));
|
||
const width = Math.max(1, Math.min(geometry.width - left, Math.round(operations.crop.width * geometry.width)));
|
||
const height = Math.max(1, Math.min(geometry.height - top, Math.round(operations.crop.height * geometry.height)));
|
||
result = document.createElement('canvas');
|
||
result.width = width;
|
||
result.height = height;
|
||
result.getContext('2d', { willReadFrequently: true }).drawImage(geometry, left, top, width, height, 0, 0, width, height);
|
||
}
|
||
|
||
applyPreviewToneAdjustments(result, operations);
|
||
applyPreviewBackgroundRemoval(result, operations);
|
||
applyPreviewWatermark(result, operations);
|
||
return result;
|
||
}
|
||
|
||
function applyPreviewBackgroundRemoval(canvas, operations) {
|
||
if (!operations.remove_background) return;
|
||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||
const data = imageData.data;
|
||
const cornerIndexes = [0, (canvas.width - 1) * 4, ((canvas.height - 1) * canvas.width) * 4, (canvas.width * canvas.height - 1) * 4];
|
||
const background = cornerIndexes.reduce((sum, index) => ({ r: sum.r + data[index], g: sum.g + data[index + 1], b: sum.b + data[index + 2] }), { r: 0, g: 0, b: 0 });
|
||
background.r /= 4; background.g /= 4; background.b /= 4;
|
||
const level = Math.max(0, Math.min(100, Number(operations.background_sensitivity ?? 45)));
|
||
const solidThreshold = 24 + level * 2.1;
|
||
const featherWidth = 45 + level * 1.2;
|
||
const featherEnd = solidThreshold + featherWidth;
|
||
for (let index = 0; index < data.length; index += 4) {
|
||
const distance = Math.abs(data[index] - background.r) + Math.abs(data[index + 1] - background.g) + Math.abs(data[index + 2] - background.b);
|
||
if (distance <= solidThreshold) data[index + 3] = 0;
|
||
else if (distance < featherEnd) data[index + 3] = Math.round(data[index + 3] * (distance - solidThreshold) / featherWidth);
|
||
}
|
||
context.putImageData(imageData, 0, 0);
|
||
}
|
||
|
||
function applyPreviewWatermark(canvas, operations) {
|
||
const watermark = operations.watermark || {};
|
||
const text = String(watermark.text || '').trim();
|
||
if (!watermark.enabled || !text) return;
|
||
const context = canvas.getContext('2d');
|
||
const margin = Math.max(10, Math.round(Math.min(canvas.width, canvas.height) * 0.025));
|
||
const fontSize = Math.max(16, Math.round(canvas.width * 0.04));
|
||
const position = watermark.position || 'bottom-right';
|
||
let x = canvas.width - margin; let y = canvas.height - margin; let align = 'right'; let baseline = 'bottom';
|
||
if (position.includes('left')) { x = margin; align = 'left'; }
|
||
if (position.includes('top')) { y = margin; baseline = 'top'; }
|
||
if (position === 'center') { x = canvas.width / 2; y = canvas.height / 2; align = 'center'; baseline = 'middle'; }
|
||
context.save();
|
||
context.globalAlpha = Math.max(0.1, Math.min(1, Number(watermark.opacity || 40) / 100));
|
||
context.font = `700 ${fontSize}px system-ui, sans-serif`;
|
||
context.textAlign = align;
|
||
context.textBaseline = baseline;
|
||
context.lineWidth = Math.max(2, fontSize * 0.08);
|
||
context.strokeStyle = 'rgba(0,0,0,.42)';
|
||
context.fillStyle = '#ffffff';
|
||
context.strokeText(text, x, y);
|
||
context.fillText(text, x, y);
|
||
context.restore();
|
||
}
|
||
|
||
function applyPreviewToneAdjustments(canvas, operations) {
|
||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||
const imageData = context.getImageData(0, 0, canvas.width, canvas.height);
|
||
const data = imageData.data;
|
||
const exposure = 2 ** operations.exposure;
|
||
const brightness = operations.brightness / 100 * 0.22;
|
||
const contrastValue = Math.max(-0.95, Math.min(0.95, operations.contrast / 100));
|
||
const contrast = (1 + contrastValue) / (1 - contrastValue);
|
||
const highlights = operations.highlights / 100;
|
||
const shadows = operations.shadows / 100;
|
||
const temperature = operations.temperature / 100;
|
||
const saturation = 1 + operations.saturation / 100;
|
||
|
||
for (let index = 0; index < data.length; index += 4) {
|
||
let red = data[index] / 255;
|
||
let green = data[index + 1] / 255;
|
||
let blue = data[index + 2] / 255;
|
||
red = (red * exposure + brightness - 0.5) * contrast + 0.5;
|
||
green = (green * exposure + brightness - 0.5) * contrast + 0.5;
|
||
blue = (blue * exposure + brightness - 0.5) * contrast + 0.5;
|
||
const lumBefore = Math.max(0, Math.min(1, red * 0.2126 + green * 0.7152 + blue * 0.0722));
|
||
const shadowWeight = (1 - lumBefore) ** 2;
|
||
const highlightWeight = lumBefore ** 2;
|
||
const shadowDelta = shadows >= 0 ? shadows * shadowWeight * 0.36 : shadows * shadowWeight * 0.28;
|
||
const highlightDelta = highlights >= 0 ? highlights * highlightWeight * 0.28 : highlights * highlightWeight * 0.38;
|
||
red += shadowDelta + highlightDelta + temperature * 0.12;
|
||
green += shadowDelta + highlightDelta + temperature * 0.015;
|
||
blue += shadowDelta + highlightDelta - temperature * 0.12;
|
||
const luminance = red * 0.2126 + green * 0.7152 + blue * 0.0722;
|
||
red = luminance + (red - luminance) * saturation;
|
||
green = luminance + (green - luminance) * saturation;
|
||
blue = luminance + (blue - luminance) * saturation;
|
||
data[index] = Math.round(Math.max(0, Math.min(1, red)) * 255);
|
||
data[index + 1] = Math.round(Math.max(0, Math.min(1, green)) * 255);
|
||
data[index + 2] = Math.round(Math.max(0, Math.min(1, blue)) * 255);
|
||
}
|
||
context.putImageData(imageData, 0, 0);
|
||
if (operations.sharpness > 0) applyPreviewSharpen(canvas, operations.sharpness / 100);
|
||
}
|
||
|
||
function applyPreviewSharpen(canvas, strength) {
|
||
if (canvas.width * canvas.height > 1_500_000) return;
|
||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||
const source = context.getImageData(0, 0, canvas.width, canvas.height);
|
||
const output = context.createImageData(canvas.width, canvas.height);
|
||
output.data.set(source.data);
|
||
const amount = Math.min(1, Math.max(0, strength)) * 0.75;
|
||
const width = canvas.width;
|
||
for (let y = 1; y < canvas.height - 1; y += 1) {
|
||
for (let x = 1; x < width - 1; x += 1) {
|
||
const index = (y * width + x) * 4;
|
||
for (let channel = 0; channel < 3; channel += 1) {
|
||
const center = source.data[index + channel];
|
||
const average = (source.data[index - 4 + channel] + source.data[index + 4 + channel] + source.data[index - width * 4 + channel] + source.data[index + width * 4 + channel]) / 4;
|
||
output.data[index + channel] = Math.max(0, Math.min(255, center + (center - average) * amount));
|
||
}
|
||
}
|
||
}
|
||
context.putImageData(output, 0, 0);
|
||
}
|
||
|
||
function calculatePhotoEditorOutputDimensions() {
|
||
const image = photoEditorState.sourceImage;
|
||
if (!image) return { width: 0, height: 0 };
|
||
const angle = (photoEditorState.operations.rotation + photoEditorState.operations.straighten) * Math.PI / 180;
|
||
const cos = Math.abs(Math.cos(angle));
|
||
const sin = Math.abs(Math.sin(angle));
|
||
let width = Math.ceil(image.naturalWidth * cos + image.naturalHeight * sin);
|
||
let height = Math.ceil(image.naturalWidth * sin + image.naturalHeight * cos);
|
||
if (photoEditorState.operations.crop) {
|
||
width = Math.max(1, Math.round(width * photoEditorState.operations.crop.width));
|
||
height = Math.max(1, Math.round(height * photoEditorState.operations.crop.height));
|
||
}
|
||
const target = photoEditorState.operations.resize || {};
|
||
if (target.width || target.height) {
|
||
const scale = Math.min(target.width ? target.width / width : Infinity, target.height ? target.height / height : Infinity, 1);
|
||
width = Math.max(1, Math.round(width * scale));
|
||
height = Math.max(1, Math.round(height * scale));
|
||
}
|
||
return { width, height };
|
||
}
|
||
|
||
function renderPhotoEditorCanvas() {
|
||
const image = photoEditorState.sourceImage;
|
||
if (!image) return;
|
||
const preview = createPreviewCanvas(image, photoEditorState.operations, { applyCrop: !photoEditorState.cropping });
|
||
const canvas = document.getElementById('photo-editor-canvas');
|
||
canvas.width = preview.width;
|
||
canvas.height = preview.height;
|
||
canvas.getContext('2d').drawImage(preview, 0, 0);
|
||
|
||
if (photoEditorState.rootImage) {
|
||
const before = createPreviewCanvas(photoEditorState.rootImage, createPhotoEditorOperations(), { maxDimension: 850 });
|
||
const beforeCanvas = document.getElementById('photo-editor-before-canvas');
|
||
beforeCanvas.width = before.width;
|
||
beforeCanvas.height = before.height;
|
||
beforeCanvas.getContext('2d').drawImage(before, 0, 0);
|
||
}
|
||
|
||
const dimensions = calculatePhotoEditorOutputDimensions();
|
||
document.getElementById('pe-output-dimensions').textContent = dimensions.width && dimensions.height ? `Ausgabe: ${dimensions.width} × ${dimensions.height} px` : 'Ausgabegröße wird ermittelt';
|
||
document.getElementById('pe-image-meta').textContent = `${photoEditorState.currentPath === photoEditorState.rootPath ? 'Original' : 'Version'} · ${image.naturalWidth} × ${image.naturalHeight} px · Original bleibt erhalten`;
|
||
requestAnimationFrame(syncCropOverlay);
|
||
}
|
||
|
||
function setPhotoEditorCompare(enabled) {
|
||
photoEditorState.compare = Boolean(enabled);
|
||
document.getElementById('pe-before-pane').classList.toggle('hidden', !photoEditorState.compare);
|
||
const button = document.getElementById('pe-compare');
|
||
button.setAttribute('aria-pressed', String(photoEditorState.compare));
|
||
button.classList.toggle('active', photoEditorState.compare);
|
||
}
|
||
|
||
function togglePhotoEditorCompare() {
|
||
setPhotoEditorCompare(!photoEditorState.compare);
|
||
requestAnimationFrame(syncCropOverlay);
|
||
}
|
||
|
||
function touchToMouse(event) {
|
||
const touch = event.touches?.[0] || event.changedTouches?.[0];
|
||
return { clientX: touch?.clientX || 0, clientY: touch?.clientY || 0, target: event.target };
|
||
}
|
||
|
||
function toggleCropMode() {
|
||
if (photoEditorState.cropping) return cancelCropMode();
|
||
photoEditorState.cropping = true;
|
||
document.getElementById('pe-crop-toggle').classList.add('active');
|
||
document.getElementById('pe-crop-toggle').textContent = 'Auswahl aktiv';
|
||
document.getElementById('pe-crop-apply').classList.remove('hidden');
|
||
document.getElementById('pe-crop-cancel').classList.remove('hidden');
|
||
document.getElementById('pe-crop-hint').textContent = 'Rahmen aufziehen oder verschieben; anschließend anwenden.';
|
||
renderPhotoEditorCanvas();
|
||
requestAnimationFrame(() => {
|
||
syncCropOverlay();
|
||
if (photoEditorState.operations.crop) {
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
photoEditorState.cropRect = {
|
||
x: photoEditorState.operations.crop.x * overlay.width,
|
||
y: photoEditorState.operations.crop.y * overlay.height,
|
||
w: photoEditorState.operations.crop.width * overlay.width,
|
||
h: photoEditorState.operations.crop.height * overlay.height,
|
||
};
|
||
drawCropOverlay();
|
||
} else setDefaultCropSelection();
|
||
});
|
||
}
|
||
|
||
function cancelCropMode(render = true) {
|
||
photoEditorState.cropping = false;
|
||
photoEditorState.cropRect = null;
|
||
photoEditorState.cropStart = null;
|
||
photoEditorState.cropEnd = null;
|
||
photoEditorState._dragOrigin = null;
|
||
photoEditorState._dragging = false;
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
overlay.classList.add('hidden');
|
||
clearCropOverlay();
|
||
const toggle = document.getElementById('pe-crop-toggle');
|
||
toggle.classList.remove('active');
|
||
toggle.textContent = '✂ Auswahl starten';
|
||
document.getElementById('pe-crop-apply').classList.add('hidden');
|
||
document.getElementById('pe-crop-cancel').classList.add('hidden');
|
||
document.getElementById('pe-crop-hint').textContent = photoEditorState.operations.crop ? 'Ein nicht destruktiver Zuschnitt ist aktiv.' : 'Zuschnitt ist nicht aktiv.';
|
||
if (render) renderPhotoEditorCanvas();
|
||
}
|
||
|
||
function syncCropOverlay() {
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
const canvas = document.getElementById('photo-editor-canvas');
|
||
const wrap = document.getElementById('photo-editor-canvas-wrap');
|
||
if (!photoEditorState.cropping || !canvas.offsetWidth || !canvas.offsetHeight) {
|
||
overlay.classList.add('hidden');
|
||
return;
|
||
}
|
||
const canvasRect = canvas.getBoundingClientRect();
|
||
const wrapRect = wrap.getBoundingClientRect();
|
||
overlay.width = Math.max(1, Math.round(canvasRect.width));
|
||
overlay.height = Math.max(1, Math.round(canvasRect.height));
|
||
overlay.style.width = `${canvasRect.width}px`;
|
||
overlay.style.height = `${canvasRect.height}px`;
|
||
overlay.style.left = `${canvasRect.left - wrapRect.left}px`;
|
||
overlay.style.top = `${canvasRect.top - wrapRect.top}px`;
|
||
overlay.classList.remove('hidden');
|
||
drawCropOverlay();
|
||
}
|
||
|
||
function getCropRatio() {
|
||
const value = document.getElementById('pe-crop-ratio').value;
|
||
if (value === 'free') return null;
|
||
if (value === 'original') {
|
||
const canvas = document.getElementById('photo-editor-canvas');
|
||
return canvas.width / Math.max(1, canvas.height);
|
||
}
|
||
const [width, height] = value.split(':').map(Number);
|
||
return width && height ? width / height : null;
|
||
}
|
||
|
||
function setDefaultCropSelection() {
|
||
if (!photoEditorState.cropping) return;
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
const ratio = getCropRatio();
|
||
const margin = Math.min(overlay.width, overlay.height) * 0.08;
|
||
let width = Math.max(20, overlay.width - margin * 2);
|
||
let height = Math.max(20, overlay.height - margin * 2);
|
||
if (ratio) {
|
||
if (width / height > ratio) width = height * ratio;
|
||
else height = width / ratio;
|
||
}
|
||
photoEditorState.cropRect = { x: (overlay.width - width) / 2, y: (overlay.height - height) / 2, w: width, h: height };
|
||
drawCropOverlay();
|
||
}
|
||
|
||
function getCropCanvasCoords(event) {
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
const rect = overlay.getBoundingClientRect();
|
||
return {
|
||
x: Math.max(0, Math.min(overlay.width, event.clientX - rect.left)),
|
||
y: Math.max(0, Math.min(overlay.height, event.clientY - rect.top)),
|
||
};
|
||
}
|
||
|
||
function cropMouseDown(event) {
|
||
if (!photoEditorState.cropping) return;
|
||
photoEditorState._dragOrigin = getCropCanvasCoords(event);
|
||
photoEditorState._dragging = true;
|
||
photoEditorState.cropStart = photoEditorState._dragOrigin;
|
||
photoEditorState.cropEnd = photoEditorState._dragOrigin;
|
||
}
|
||
|
||
function cropMouseMove(event) {
|
||
if (!photoEditorState.cropping || !photoEditorState._dragging || !photoEditorState._dragOrigin) return;
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
const origin = photoEditorState._dragOrigin;
|
||
let point = getCropCanvasCoords(event);
|
||
const ratio = getCropRatio();
|
||
if (ratio) {
|
||
const directionX = point.x >= origin.x ? 1 : -1;
|
||
const directionY = point.y >= origin.y ? 1 : -1;
|
||
let width = Math.abs(point.x - origin.x);
|
||
let height = Math.abs(point.y - origin.y);
|
||
if (width / Math.max(1, height) > ratio) height = width / ratio;
|
||
else width = height * ratio;
|
||
width = Math.min(width, directionX > 0 ? overlay.width - origin.x : origin.x);
|
||
height = Math.min(height, directionY > 0 ? overlay.height - origin.y : origin.y);
|
||
if (width / Math.max(1, height) > ratio) width = height * ratio;
|
||
else height = width / ratio;
|
||
point = { x: origin.x + width * directionX, y: origin.y + height * directionY };
|
||
}
|
||
photoEditorState.cropEnd = point;
|
||
const x = Math.min(origin.x, point.x);
|
||
const y = Math.min(origin.y, point.y);
|
||
photoEditorState.cropRect = { x, y, w: Math.abs(point.x - origin.x), h: Math.abs(point.y - origin.y) };
|
||
drawCropOverlay();
|
||
}
|
||
|
||
function cropMouseUp() {
|
||
if (!photoEditorState.cropping) return;
|
||
photoEditorState._dragging = false;
|
||
photoEditorState._dragOrigin = null;
|
||
}
|
||
|
||
function drawCropOverlay() {
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
const context = overlay.getContext('2d');
|
||
context.clearRect(0, 0, overlay.width, overlay.height);
|
||
if (!photoEditorState.cropping) return;
|
||
context.fillStyle = 'rgba(7, 11, 17, 0.58)';
|
||
context.fillRect(0, 0, overlay.width, overlay.height);
|
||
const rect = photoEditorState.cropRect;
|
||
if (!rect || rect.w < 2 || rect.h < 2) return;
|
||
context.clearRect(rect.x, rect.y, rect.w, rect.h);
|
||
context.strokeStyle = '#ffffff';
|
||
context.lineWidth = 2;
|
||
context.setLineDash([]);
|
||
context.strokeRect(rect.x, rect.y, rect.w, rect.h);
|
||
context.strokeStyle = 'rgba(255,255,255,.42)';
|
||
context.lineWidth = 1;
|
||
for (let index = 1; index < 3; index += 1) {
|
||
context.beginPath(); context.moveTo(rect.x + rect.w * index / 3, rect.y); context.lineTo(rect.x + rect.w * index / 3, rect.y + rect.h); context.stroke();
|
||
context.beginPath(); context.moveTo(rect.x, rect.y + rect.h * index / 3); context.lineTo(rect.x + rect.w, rect.y + rect.h * index / 3); context.stroke();
|
||
}
|
||
const canvas = document.getElementById('photo-editor-canvas');
|
||
const realWidth = Math.round(rect.w * canvas.width / overlay.width);
|
||
const realHeight = Math.round(rect.h * canvas.height / overlay.height);
|
||
const label = `${realWidth} × ${realHeight}`;
|
||
context.font = '12px ui-monospace, monospace';
|
||
const textWidth = context.measureText(label).width;
|
||
context.fillStyle = 'rgba(0,0,0,.74)';
|
||
context.fillRect(rect.x + rect.w / 2 - textWidth / 2 - 7, Math.max(rect.y + 5, rect.y + rect.h - 25), textWidth + 14, 20);
|
||
context.fillStyle = '#ffffff';
|
||
context.textAlign = 'center';
|
||
context.fillText(label, rect.x + rect.w / 2, Math.max(rect.y + 19, rect.y + rect.h - 11));
|
||
}
|
||
|
||
function clearCropOverlay() {
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
overlay.getContext('2d').clearRect(0, 0, overlay.width, overlay.height);
|
||
}
|
||
|
||
function applyCropSelection() {
|
||
const rect = photoEditorState.cropRect;
|
||
const overlay = document.getElementById('photo-editor-crop-overlay');
|
||
if (!rect || rect.w < 10 || rect.h < 10) return toast('Bitte zuerst einen gültigen Zuschnitt wählen.');
|
||
photoEditorState.operations.crop = {
|
||
x: Math.max(0, Math.min(1, rect.x / overlay.width)),
|
||
y: Math.max(0, Math.min(1, rect.y / overlay.height)),
|
||
width: Math.max(0.01, Math.min(1, rect.w / overlay.width)),
|
||
height: Math.max(0.01, Math.min(1, rect.h / overlay.height)),
|
||
};
|
||
cancelCropMode(false);
|
||
pushPhotoEditorHistory();
|
||
renderPhotoEditorCanvas();
|
||
}
|
||
|
||
async function restorePhotoEditorOriginal() {
|
||
if (!photoEditorState.rootPath) return;
|
||
try {
|
||
await commitPhotoEditorResult(photoEditorState.rootPath);
|
||
toast('Unverändertes Original wiederhergestellt.');
|
||
closePhotoEditor();
|
||
} catch (error) { toast(error.message || 'Original konnte nicht wiederhergestellt werden.'); }
|
||
}
|
||
|
||
async function applyPhotoEdit() {
|
||
const button = document.getElementById('pe-apply');
|
||
const originalLabel = button.textContent;
|
||
button.disabled = true;
|
||
button.textContent = 'Neue Version wird gerendert …';
|
||
try {
|
||
const result = await api('POST', '/api/image-editor/render', {
|
||
source_path: photoEditorState.currentPath,
|
||
context: photoEditorState.context,
|
||
platform: getPhotoEditorPlatform(),
|
||
operations: photoEditorState.operations,
|
||
});
|
||
await commitPhotoEditorResult(result.filename);
|
||
toast(`Neue Bildversion gespeichert (${result.metadata?.width || '?'} × ${result.metadata?.height || '?'} px).`);
|
||
closePhotoEditor();
|
||
} catch (error) {
|
||
toast(error.message || 'Bildversion konnte nicht gespeichert werden.');
|
||
} finally {
|
||
button.disabled = false;
|
||
button.textContent = originalLabel;
|
||
}
|
||
}
|
||
|
||
function closePhotoEditor() {
|
||
const overlay = document.getElementById('photo-editor-overlay');
|
||
overlay.classList.add('hidden');
|
||
overlay.setAttribute('aria-hidden', 'true');
|
||
cancelCropMode(false);
|
||
photoEditorState.sourceImage = null;
|
||
photoEditorState.rootImage = null;
|
||
photoEditorState.versions = [];
|
||
}
|
||
|
||
|
||
// --- Photo Drag & Drop Sorting ---
|
||
function enablePhotoDragSort() {
|
||
const container = document.getElementById('photo-preview');
|
||
let dragIdx = -1;
|
||
|
||
container.addEventListener('dragstart', e => {
|
||
const thumb = e.target.closest('.thumb');
|
||
if (!thumb) return;
|
||
dragIdx = [...container.children].indexOf(thumb);
|
||
thumb.classList.add('dragging');
|
||
e.dataTransfer.effectAllowed = 'move';
|
||
});
|
||
|
||
container.addEventListener('dragover', e => {
|
||
e.preventDefault();
|
||
e.dataTransfer.dropEffect = 'move';
|
||
const thumb = e.target.closest('.thumb');
|
||
if (thumb) {
|
||
container.querySelectorAll('.thumb').forEach(t => t.classList.remove('drag-over'));
|
||
thumb.classList.add('drag-over');
|
||
}
|
||
});
|
||
|
||
container.addEventListener('drop', e => {
|
||
e.preventDefault();
|
||
container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); });
|
||
const thumb = e.target.closest('.thumb');
|
||
if (!thumb) return;
|
||
const dropIdx = [...container.children].indexOf(thumb);
|
||
if (dragIdx >= 0 && dropIdx >= 0 && dragIdx !== dropIdx) {
|
||
const moved = state.photos.splice(dragIdx, 1)[0];
|
||
state.photos.splice(dropIdx, 0, moved);
|
||
renderPhotoPreview();
|
||
}
|
||
dragIdx = -1;
|
||
});
|
||
|
||
container.addEventListener('dragend', () => {
|
||
container.querySelectorAll('.thumb').forEach(t => { t.classList.remove('dragging', 'drag-over'); });
|
||
dragIdx = -1;
|
||
});
|
||
}
|
||
|
||
// --- Price Suggestion ---
|
||
async function fetchPriceSuggestion() {
|
||
const title = document.getElementById('result-title')?.value;
|
||
const category = document.getElementById('meta-category-picker')?.textContent;
|
||
if (!title) return;
|
||
try {
|
||
const data = await api('GET', `/api/price-suggest?title=${encodeURIComponent(title)}&category=${encodeURIComponent(category || '')}`);
|
||
if (data.suggestion) {
|
||
const priceInput = document.getElementById('meta-price');
|
||
const currentPrice = parseFloat(priceInput.value);
|
||
const hint = document.getElementById('price-suggestion-hint');
|
||
const html = `Preisvorschlag: <strong>${data.suggestion}€</strong> (${data.min}–${data.max}€, ${data.count} ähnliche)`;
|
||
if (hint) {
|
||
hint.innerHTML = html;
|
||
hint.classList.remove('hidden');
|
||
} else {
|
||
const el = document.createElement('div');
|
||
el.id = 'price-suggestion-hint';
|
||
el.style.cssText = 'font-size:12px;color:var(--text-muted);margin-top:4px;cursor:pointer';
|
||
el.innerHTML = html;
|
||
el.addEventListener('click', () => { priceInput.value = data.suggestion; });
|
||
priceInput.parentElement.appendChild(el);
|
||
}
|
||
}
|
||
} catch {}
|
||
}
|
||
|
||
// --- Backup & Restore (now handled in setupAdmin for system tab) ---
|
||
function setupBackup() {
|
||
// Legacy backup buttons in settings - no longer exist, backup is in admin > system tab
|
||
}
|
||
|
||
// --- Browser Notifications ---
|
||
function setupNotifications() {
|
||
if (!('Notification' in window)) return;
|
||
if (Notification.permission === 'default') {
|
||
Notification.requestPermission();
|
||
}
|
||
}
|
||
|
||
function notify(title, body) {
|
||
if (!('Notification' in window) || Notification.permission !== 'granted') return;
|
||
new Notification(title, { body, icon: '/favicon.ico' });
|
||
}
|
||
|
||
// --- Permissions & collaborative editing ---
|
||
function userCan(permission) {
|
||
return Boolean(currentUser?.role === 'admin' || currentUser?.permissions?.includes(permission));
|
||
}
|
||
|
||
function applyPermissionUi() {
|
||
const rules = [
|
||
['generator', 'listings.edit'], ['image-factory', 'batch.execute'], ['flux-studio', 'generation.execute'],
|
||
['publish', 'publishing.view'], ['quality-center', 'listings.view'], ['inventory', 'listings.view'],
|
||
];
|
||
for (const [tab, permission] of rules) {
|
||
const button = document.querySelector(`.nav-item[data-tab="${tab}"]`);
|
||
if (button) button.classList.toggle('hidden', !userCan(permission));
|
||
}
|
||
document.querySelectorAll('[data-requires-permission]').forEach(element => {
|
||
const permission = element.dataset.requiresPermission;
|
||
element.classList.toggle('hidden', !userCan(permission));
|
||
if ('disabled' in element) element.disabled = !userCan(permission);
|
||
});
|
||
document.body.dataset.userRole = currentUser?.role || 'viewer';
|
||
}
|
||
|
||
function clearListingLockTimer() {
|
||
if (listingEditLock.timer) window.clearInterval(listingEditLock.timer);
|
||
listingEditLock.timer = null;
|
||
}
|
||
|
||
async function releaseListingLock({ quiet = true, keepalive = false } = {}) {
|
||
clearListingLockTimer();
|
||
if (!listingEditLock.listingId || !listingEditLock.token) {
|
||
listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null;
|
||
return;
|
||
}
|
||
const id = listingEditLock.listingId;
|
||
const token = listingEditLock.token;
|
||
listingEditLock.listingId = null; listingEditLock.token = null; listingEditLock.lock = null;
|
||
try {
|
||
if (keepalive) {
|
||
await fetch(`/api/locks/listing/${id}`, {
|
||
method: 'DELETE', credentials: 'same-origin', keepalive: true,
|
||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrf() },
|
||
body: JSON.stringify({ token }),
|
||
});
|
||
} else await api('DELETE', `/api/locks/listing/${id}`, { token }, { retry: false, timeoutMs: 8000 });
|
||
} catch (error) { if (!quiet) toast(error.message || 'Bearbeitungssperre konnte nicht gelöst werden.'); }
|
||
}
|
||
|
||
function showListingLockBanner(lock, owned = false) {
|
||
document.getElementById('listing-lock-banner')?.remove();
|
||
const detail = document.querySelector('.listing-editor-shell');
|
||
if (!detail || !lock) return;
|
||
const banner = document.createElement('div');
|
||
banner.id = 'listing-lock-banner';
|
||
banner.className = `listing-lock-banner ${owned ? 'is-mine' : 'is-foreign'}`;
|
||
banner.innerHTML = owned
|
||
? `<span data-vd-icon="lock-keyhole"></span><div><strong>Bearbeitung für dich reserviert</strong><p>Andere Benutzer können diesen Artikel ansehen, aber nicht gleichzeitig speichern.</p></div>`
|
||
: `<span data-vd-icon="lock"></span><div><strong>${escapeHtml(lock.user_name || lock.user_email || 'Ein anderer Benutzer')} bearbeitet diesen Artikel</strong><p>Du kannst den Artikel ansehen. Speichern ist bis zum Ablauf der Sperre deaktiviert.</p></div>`;
|
||
detail.prepend(banner);
|
||
const save = document.getElementById('detail-save');
|
||
if (save && !owned) { save.disabled = true; save.title = 'Artikel ist durch einen anderen Benutzer gesperrt'; }
|
||
}
|
||
|
||
async function acquireListingLock(listingId) {
|
||
await releaseListingLock({ quiet: true });
|
||
if (!listingId || !userCan('listings.edit')) return { acquired: false, readOnly: true };
|
||
try {
|
||
const result = await api('POST', `/api/locks/listing/${listingId}/acquire`, { client_label: `${navigator.platform || 'Browser'} · Artikel-Editor`, ttl_seconds: 120 });
|
||
listingEditLock.listingId = Number(listingId);
|
||
listingEditLock.token = result.token;
|
||
listingEditLock.lock = result.lock;
|
||
showListingLockBanner(result.lock, true);
|
||
listingEditLock.timer = window.setInterval(async () => {
|
||
if (!listingEditLock.listingId || !listingEditLock.token) return;
|
||
try {
|
||
const renewed = await api('POST', `/api/locks/listing/${listingEditLock.listingId}/renew`, { token: listingEditLock.token, ttl_seconds: 120 }, { timeoutMs: 10000, retry: false });
|
||
listingEditLock.lock = renewed.lock;
|
||
} catch (error) {
|
||
clearListingLockTimer();
|
||
const save = document.getElementById('detail-save');
|
||
if (save) save.disabled = true;
|
||
toast(`Bearbeitungssperre verloren: ${error.message}`);
|
||
}
|
||
}, 45000);
|
||
return { acquired: true, lock: result.lock };
|
||
} catch (error) {
|
||
if (error.status === 409) {
|
||
showListingLockBanner(error.lock || { user_name: 'Ein anderer Benutzer' }, false);
|
||
return { acquired: false, readOnly: true, lock: error.lock };
|
||
}
|
||
toast(`Bearbeitungssperre konnte nicht erstellt werden: ${error.message}`);
|
||
return { acquired: false, readOnly: true };
|
||
}
|
||
}
|
||
|
||
window.addEventListener('beforeunload', () => { releaseListingLock({ quiet: true, keepalive: true }); });
|
||
|
||
// --- User Menu ---
|
||
let currentUser = null;
|
||
|
||
async function setupUserMenu() {
|
||
try {
|
||
currentUser = await api('GET', '/api/me');
|
||
if (!currentUser || currentUser.setupMode) {
|
||
document.getElementById('user-menu').classList.add('hidden');
|
||
return;
|
||
}
|
||
_csrfToken = currentUser.csrf || getCsrf();
|
||
|
||
// Populate sidebar user area
|
||
const initials = currentUser.initials || currentUser.name?.[0]?.toUpperCase() || '?';
|
||
const sidebarAvatar = document.getElementById('sidebar-avatar');
|
||
if (sidebarAvatar) {
|
||
sidebarAvatar.textContent = initials;
|
||
sidebarAvatar.style.background = currentUser.avatar_color || 'var(--accent)';
|
||
}
|
||
const sidebarName = document.getElementById('sidebar-name');
|
||
if (sidebarName) sidebarName.textContent = currentUser.name || currentUser.email || '';
|
||
const sidebarRole = document.getElementById('sidebar-role');
|
||
if (sidebarRole) sidebarRole.textContent = currentUser.role || '';
|
||
const topbarAvatar = document.getElementById('topbar-avatar');
|
||
if (topbarAvatar) {
|
||
topbarAvatar.textContent = initials;
|
||
topbarAvatar.style.background = currentUser.avatar_color || 'var(--vd-ink)';
|
||
topbarAvatar.title = currentUser.name || currentUser.email || '';
|
||
}
|
||
|
||
const adminNav = document.getElementById('nav-admin-main');
|
||
if (adminNav) adminNav.classList.toggle('hidden', !['admin', 'manager'].includes(currentUser.role));
|
||
if (currentUser.role === 'manager') {
|
||
document.querySelectorAll('.admin-tab:not([data-admin-tab="audit"])').forEach(tab => tab.classList.add('hidden'));
|
||
document.getElementById('invite-user-btn')?.classList.add('hidden');
|
||
adminNav?.setAttribute('data-admin-tab', 'audit');
|
||
}
|
||
applyPermissionUi();
|
||
|
||
// Logout
|
||
document.getElementById('sidebar-logout-btn')?.addEventListener('click', async () => {
|
||
await releaseListingLock({ quiet: true });
|
||
await fetch('/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-CSRF-Token': _csrfToken || getCsrf() } });
|
||
window.location.href = '/login.html';
|
||
});
|
||
} catch {
|
||
document.getElementById('sidebar-user')?.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
|
||
// --- Operations Center 1.30.0 ---
|
||
const operationsState = { status: null, backups: [], extensions: null, restoreFile: null, updateFile: null };
|
||
|
||
function operationFormatBytes(value) {
|
||
const bytes = Number(value || 0);
|
||
if (!bytes) return '0 B';
|
||
const units = ['B', 'KB', 'MB', 'GB'];
|
||
const index = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024)));
|
||
return `${(bytes / (1024 ** index)).toLocaleString('de-DE', { maximumFractionDigits: index ? 1 : 0 })} ${units[index]}`;
|
||
}
|
||
|
||
function operationFormatDate(value) {
|
||
if (!value) return '—';
|
||
try { return new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z')).toLocaleString('de-DE', { dateStyle: 'medium', timeStyle: 'short' }); }
|
||
catch { return String(value); }
|
||
}
|
||
|
||
async function operationsUploadRequest(url, field, file) {
|
||
const form = new FormData();
|
||
form.append(field, file);
|
||
const response = await fetch(url, { method: 'POST', body: form, headers: { 'X-CSRF-Token': getCsrf() }, credentials: 'same-origin' });
|
||
const data = await response.json().catch(() => ({}));
|
||
if (!response.ok) throw new Error(data.error || `HTTP ${response.status}`);
|
||
return data;
|
||
}
|
||
|
||
function renderOperationsStatus() {
|
||
const status = operationsState.status || {};
|
||
const pending = status.pending;
|
||
const banner = document.getElementById('operations-pending-banner');
|
||
if (banner) {
|
||
banner.classList.toggle('hidden', !pending);
|
||
if (pending) banner.innerHTML = `<span data-vd-icon="alert-triangle"></span><div><strong>${pending.type === 'update' ? `Update auf ${escapeHtml(pending.version_to || 'neue Version')}` : 'Wiederherstellung'} ist vorbereitet</strong><p>Vendoo beenden und <code>setup.bat</code> → <b>11 – Vorbereitete Operation anwenden</b> ausführen.</p></div><button type="button" class="secondary-btn" id="operations-pending-cancel">Vorbereitung verwerfen</button>`;
|
||
}
|
||
document.getElementById('operations-pending-cancel')?.addEventListener('click', async () => {
|
||
if (!confirm('Vorbereitete Operation wirklich verwerfen?')) return;
|
||
try { await api('POST', '/api/operations/pending/cancel'); toast('Vorbereitung verworfen.'); await loadOperationsCenter(); }
|
||
catch (error) { toast(error.message || 'Vorbereitung konnte nicht verworfen werden.'); }
|
||
});
|
||
|
||
const version = document.getElementById('operations-version-card');
|
||
if (version) {
|
||
const last = status.last;
|
||
version.innerHTML = `<strong>Vendoo ${escapeHtml(status.version || VENDOO_UI_BUILD)}</strong><span>${last ? `Letzte Operation: ${escapeHtml(last.type || 'System')} · ${escapeHtml(last.status || 'unbekannt')}` : 'Noch keine Setup-Operation protokolliert.'}</span>`;
|
||
}
|
||
const migrations = document.getElementById('operations-migration-list');
|
||
if (migrations) {
|
||
const rows = status.migrations || [];
|
||
migrations.innerHTML = rows.length ? rows.slice(0, 8).map(row => `<div><span data-vd-icon="check-circle-2"></span><p><strong>${escapeHtml(row.app_version)}</strong><small>${escapeHtml(row.description || row.migration_key)} · ${operationFormatDate(row.applied_at)}</small></p></div>`).join('') : '<div class="empty-state">Keine Migrationsinformationen vorhanden.</div>';
|
||
}
|
||
|
||
const settings = status.settings || {};
|
||
const enabled = document.getElementById('operations-auto-enabled'); if (enabled) enabled.checked = !!settings.auto_backup_enabled;
|
||
const hour = document.getElementById('operations-auto-hour'); if (hour) hour.value = String(settings.auto_backup_hour ?? 3);
|
||
const retention = document.getElementById('operations-auto-retention'); if (retention) retention.value = String(settings.auto_backup_retention ?? 7);
|
||
const uploads = document.getElementById('operations-auto-uploads'); if (uploads) uploads.checked = settings.include_uploads_automatic !== false;
|
||
const updateUrl = document.getElementById('operations-update-url'); if (updateUrl && document.activeElement !== updateUrl) updateUrl.value = settings.update_manifest_url || '';
|
||
window.VendooIcons?.render(document.getElementById('admin-system-panel'));
|
||
}
|
||
|
||
function renderOperationsBackups() {
|
||
const list = document.getElementById('operations-backup-list');
|
||
const summary = document.getElementById('operations-backup-summary');
|
||
if (!list) return;
|
||
const backups = operationsState.backups || [];
|
||
const total = backups.reduce((sum, item) => sum + Number(item.file_size || 0), 0);
|
||
if (summary) summary.textContent = `${backups.length} Sicherung(en) · ${operationFormatBytes(total)}`;
|
||
if (!backups.length) {
|
||
list.innerHTML = '<div class="empty-state">Noch kein Backup vorhanden.</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = backups.map(item => {
|
||
const verified = item.status === 'verified';
|
||
const includesUploads = item.options?.includeUploads;
|
||
return `<article class="operations-backup-row${item.exists ? '' : ' is-missing'}">
|
||
<div class="operation-backup-icon"><span data-vd-icon="${verified ? 'shield-check' : 'archive'}"></span></div>
|
||
<div class="operation-backup-main"><strong>${escapeHtml(item.label || item.file_name)}</strong><span>${escapeHtml(item.kind === 'automatic' ? 'Automatisch' : item.kind === 'database' ? 'Nur Datenbank' : 'Manuell')} · ${operationFormatDate(item.created_at)}</span><small>${operationFormatBytes(item.file_size)} · ${includesUploads ? 'mit Uploads' : 'ohne Uploads'}${item.options?.includeConfig ? ' · enthält .env' : ''} · ${verified ? 'verifiziert' : escapeHtml(item.status)}</small></div>
|
||
<code class="operation-backup-hash" title="SHA-256">${escapeHtml(String(item.sha256 || '').slice(0, 16))}…</code>
|
||
<div class="operation-backup-actions">
|
||
<a class="icon-button" href="/api/operations/backups/${encodeURIComponent(item.id)}/download" title="Herunterladen"><span data-vd-icon="download"></span></a>
|
||
<button type="button" class="icon-button" data-operation-backup-verify="${escapeHtml(item.id)}" title="Prüfen"><span data-vd-icon="scan-check"></span></button>
|
||
<button type="button" class="icon-button danger" data-operation-backup-delete="${escapeHtml(item.id)}" title="Löschen"><span data-vd-icon="trash-2"></span></button>
|
||
</div>
|
||
</article>`;
|
||
}).join('');
|
||
list.querySelectorAll('[data-operation-backup-verify]').forEach(button => button.addEventListener('click', async () => {
|
||
button.disabled = true;
|
||
try {
|
||
const result = await api('POST', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupVerify)}/verify`);
|
||
toast(result.ok ? 'Backup vollständig verifiziert.' : `Backup fehlerhaft: ${(result.errors || []).join(', ')}`);
|
||
await loadOperationsBackups();
|
||
} catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
list.querySelectorAll('[data-operation-backup-delete]').forEach(button => button.addEventListener('click', async () => {
|
||
if (!confirm('Dieses Backup endgültig löschen?')) return;
|
||
try { await api('DELETE', `/api/operations/backups/${encodeURIComponent(button.dataset.operationBackupDelete)}`); toast('Backup gelöscht.'); await loadOperationsBackups(); }
|
||
catch (error) { toast(error.message); }
|
||
}));
|
||
window.VendooIcons?.render(list);
|
||
}
|
||
|
||
function renderOperationsExtensions() {
|
||
const data = operationsState.extensions || {};
|
||
const summary = document.getElementById('operations-extension-summary');
|
||
const packages = document.getElementById('operations-extension-packages');
|
||
const clients = document.getElementById('operations-extension-clients');
|
||
if (summary) summary.innerHTML = `<article><span data-vd-icon="plug-zap"></span><div><strong>${Number(data.connected || 0)} Browser live verbunden</strong><p>Erwartete Extension-Version: ${escapeHtml(data.expected_version || '—')}</p></div></article>`;
|
||
if (packages) packages.innerHTML = Object.entries(data.packages || {}).map(([browser, item]) => `<article class="${item.exists && item.version === data.expected_version ? 'is-ok' : 'is-warning'}"><span data-vd-icon="${item.exists ? 'package-check' : 'package-x'}"></span><div><strong>${escapeHtml(browser[0].toUpperCase() + browser.slice(1))}</strong><small>${item.exists ? `Paket v${escapeHtml(item.version || '?')}` : 'Paket fehlt'}${item.version && item.version !== data.expected_version ? ` · erwartet ${escapeHtml(data.expected_version)}` : ''}</small></div></article>`).join('');
|
||
if (clients) {
|
||
const rows = data.clients || [];
|
||
clients.innerHTML = rows.length ? rows.map(client => {
|
||
const live = Number(client.age_seconds || 999999) <= 180;
|
||
return `<article class="operations-extension-client${live ? ' is-live' : ''}"><span class="operation-live-dot"></span><div><strong>${escapeHtml(client.browser || 'Browser')} · v${escapeHtml(client.version || '?')}</strong><span>${escapeHtml(client.platform || 'Extension')} · zuletzt ${Number(client.age_seconds || 0) < 60 ? 'gerade eben' : `vor ${Math.max(1, Math.round(Number(client.age_seconds || 0) / 60))} Min.`}</span><small>${escapeHtml(client.server_url || '')}</small></div><b>${live ? 'Live' : 'Offline'}</b></article>`;
|
||
}).join('') : '<div class="empty-state">Noch keine Extension-Verbindung gemeldet. Öffne nach dem Update die Extension oder eine unterstützte Vinted-Seite.</div>';
|
||
}
|
||
window.VendooIcons?.render(document.getElementById('operations-extensions-panel'));
|
||
}
|
||
|
||
async function loadOperationsBackups() {
|
||
try { operationsState.backups = (await api('GET', '/api/operations/backups')).backups || []; renderOperationsBackups(); }
|
||
catch (error) { const list = document.getElementById('operations-backup-list'); if (list) list.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
|
||
}
|
||
|
||
async function loadOperationsExtensions() {
|
||
try { operationsState.extensions = await api('GET', '/api/extensions/diagnostics'); renderOperationsExtensions(); }
|
||
catch (error) { const box = document.getElementById('operations-extension-clients'); if (box) box.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
|
||
}
|
||
|
||
function deploymentStateLabel(state) {
|
||
return ({
|
||
checking: 'Prüfung', backup_pending: 'Backup geplant', backup_running: 'Backup läuft',
|
||
backup_verified: 'Backup verifiziert', deploy_requested: 'Deployment angefordert',
|
||
deploy_running: 'Deployment läuft', health_wait: 'Readiness wird geprüft',
|
||
succeeded: 'Erfolgreich', failed: 'Fehlgeschlagen', rollback_required: 'Rollback/Prüfung erforderlich',
|
||
})[state] || state || 'Unbekannt';
|
||
}
|
||
|
||
function deploymentStateClass(state) {
|
||
if (state === 'succeeded') return 'is-ok';
|
||
if (state === 'failed' || state === 'rollback_required') return 'is-failed';
|
||
return 'is-running';
|
||
}
|
||
|
||
function renderProductionOverview(data) {
|
||
const box = document.getElementById('deployment-production-overview');
|
||
if (!box) return;
|
||
const production = data.production || {};
|
||
const readiness = production.readiness || {};
|
||
const storage = production.storage || {};
|
||
const lastBackup = production.last_backup;
|
||
const flux = production.flux || {};
|
||
const cards = [
|
||
['Version', production.version || data.current_version || VENDOO_UI_BUILD, production.build_revision ? `Build ${String(production.build_revision).slice(0, 12)}` : 'Keine Build-Revision gemeldet', true],
|
||
['Readiness', readiness.ok ? 'Bereit' : 'Nicht bereit', readiness.error || readiness.message || 'Interner Readiness-Check', !!readiness.ok],
|
||
['Datenbank', production.database?.integrity === 'ok' ? 'Integrität OK' : String(production.database?.integrity || 'Unbekannt'), production.database?.path || 'Kein Pfad gemeldet', production.database?.integrity === 'ok'],
|
||
['Persistente Daten', operationFormatBytes(storage.data?.bytes || 0), `${Number(storage.data?.files || 0)} Einträge · Uploads ${operationFormatBytes(storage.uploads?.bytes || 0)}`, true],
|
||
['Letztes Backup', lastBackup ? (lastBackup.status === 'verified' ? 'Verifiziert' : lastBackup.status) : 'Nicht vorhanden', lastBackup ? `${operationFormatDate(lastBackup.created_at)} · ${operationFormatBytes(lastBackup.file_size)}` : 'Vor dem ersten Deployment erforderlich', lastBackup?.status === 'verified'],
|
||
['FLUX Studio', flux.enabled ? 'Aktiv' : 'Deaktiviert', flux.enabled ? 'Produktionsgrenze prüfen' : 'Wie vorgesehen deaktiviert', !flux.enabled],
|
||
];
|
||
box.innerHTML = cards.map(([label, value, detail, ok]) => `<article class="deployment-production-item ${ok ? 'is-ok' : 'is-warning'}"><span data-vd-icon="${ok ? 'check-circle-2' : 'triangle-alert'}"></span><div><small>${escapeHtml(label)}</small><strong>${escapeHtml(String(value))}</strong><p>${escapeHtml(String(detail || ''))}</p></div></article>`).join('');
|
||
|
||
const checkBox = document.getElementById('deployment-production-check-result');
|
||
const check = data.latest_production_check;
|
||
if (checkBox && check) {
|
||
checkBox.innerHTML = `<strong>${check.overall_status === 'passed' ? 'Produktionscheck bestanden' : check.overall_status === 'warning' ? 'Produktionscheck mit Hinweisen' : 'Produktionscheck fehlgeschlagen'}</strong><span>${Number(check.passed_count || 0)} bestanden · ${Number(check.warning_count || 0)} Hinweise · ${Number(check.failed_count || 0)} Fehler · ${operationFormatDate(check.created_at || check.verified_at)}</span>`;
|
||
}
|
||
}
|
||
|
||
function renderDeploymentRuns(data) {
|
||
const list = document.getElementById('deployment-run-list');
|
||
if (!list) return;
|
||
const runs = data.recent_runs || [];
|
||
if (!runs.length) { list.innerHTML = '<div class="empty-state">Keine Deployment-Aufträge vorhanden.</div>'; return; }
|
||
list.innerHTML = runs.map(run => {
|
||
const lastEvent = run.events?.at?.(-1);
|
||
const active = !['succeeded', 'failed'].includes(run.state);
|
||
return `<article class="deployment-run ${deploymentStateClass(run.state)}">
|
||
<div class="deployment-run-head"><span class="deployment-run-state">${escapeHtml(deploymentStateLabel(run.state))}</span><time>${operationFormatDate(run.created_at)}</time></div>
|
||
<strong>${escapeHtml(run.current_version || '?')} → ${escapeHtml(run.target_version || '?')}</strong>
|
||
<p>${escapeHtml(lastEvent?.message || run.error_message || run.reason || 'Deployment-Auftrag')}</p>
|
||
<small>${escapeHtml(run.github_branch || 'main')}${run.backup_id ? ' · Backup verifiziert' : ''}${run.deployment_id ? ` · ID ${escapeHtml(String(run.deployment_id).slice(0, 18))}` : ''}</small>
|
||
${active ? `<div class="deployment-run-actions">${run.state !== 'rollback_required' ? `<button type="button" class="secondary-btn compact" data-deployment-refresh="${escapeHtml(run.id)}">Status aktualisieren</button>` : ''}${data.trigger_mode === 'webhook' && ['deploy_requested','deploy_running','health_wait'].includes(run.state) ? `<button type="button" class="secondary-btn compact" data-deployment-confirm="${escapeHtml(run.id)}">Externen Abschluss bestätigen</button>` : ''}${run.state === 'rollback_required' ? `<button type="button" class="secondary-btn compact" data-deployment-resolve="${escapeHtml(run.id)}">Prüfung abschließen</button>` : ''}</div>` : ''}
|
||
</article>`;
|
||
}).join('');
|
||
list.querySelectorAll('[data-deployment-refresh]').forEach(button => button.addEventListener('click', async () => {
|
||
button.disabled = true;
|
||
try { await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentRefresh)}/refresh`); await loadDeploymentStatus(); }
|
||
catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
list.querySelectorAll('[data-deployment-confirm]').forEach(button => button.addEventListener('click', async () => {
|
||
const confirmation = prompt('Nur bestätigen, wenn Coolify das Deployment wirklich abgeschlossen hat. Exakt CONFIRM eingeben:');
|
||
if (confirmation !== 'CONFIRM') return;
|
||
button.disabled = true;
|
||
try {
|
||
await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentConfirm)}/confirm`, { confirmation, outcome: 'succeeded', message: 'Im Vendoo Update Center extern bestätigt.' });
|
||
toast('Externer Abschluss wurde erfasst und Readiness erneut geprüft.');
|
||
await loadDeploymentStatus();
|
||
} catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
list.querySelectorAll('[data-deployment-resolve]').forEach(button => button.addEventListener('click', async () => {
|
||
const confirmation = prompt('Der Auftrag bleibt als fehlgeschlagen dokumentiert und die Deployment-Sperre wird aufgehoben. Exakt CONFIRM eingeben:');
|
||
if (confirmation !== 'CONFIRM') return;
|
||
button.disabled = true;
|
||
try {
|
||
await api('POST', `/api/admin/deployment/runs/${encodeURIComponent(button.dataset.deploymentResolve)}/confirm`, { confirmation, outcome: 'failed', message: 'Rollback-/Fehlerprüfung manuell abgeschlossen.' });
|
||
toast('Prüfung abgeschlossen. Ein neuer Deployment-Auftrag ist wieder möglich.');
|
||
await loadDeploymentStatus();
|
||
} catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
}
|
||
|
||
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-status-endpoint', data.deployment_status_endpoint || '/api/v1/deployments/{deployment_id}');
|
||
setValue('deployment-timeout-seconds', data.deployment_timeout_seconds || 900);
|
||
setValue('deployment-poll-seconds', data.health_poll_seconds || 15);
|
||
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 target = document.getElementById('deployment-target-version');
|
||
if (target && document.activeElement !== target && !target.value) target.value = data.active_run?.target_version || data.current_version || '';
|
||
const backup = document.getElementById('deployment-backup-before'); if (backup) backup.checked = data.backup_before_deploy !== false;
|
||
const uploads = document.getElementById('deployment-backup-uploads'); if (uploads) uploads.checked = data.backup_uploads === true;
|
||
const webhook = document.getElementById('deployment-webhook-url'); if (webhook && document.activeElement !== webhook) { webhook.value = ''; webhook.placeholder = data.deploy_webhook?.configured ? `Gespeichert: ${data.deploy_webhook.masked || '••••••••'}` : 'Coolify Deploy-Webhook eintragen'; }
|
||
const token = document.getElementById('deployment-api-token'); if (token && document.activeElement !== token) { token.value = ''; token.placeholder = data.api_token?.configured ? `Gespeichert: ${data.api_token.masked || '••••••••'}` : 'Nur für API-Modus'; }
|
||
const status = document.getElementById('deployment-status');
|
||
if (status) status.innerHTML = `<strong>${data.configured ? 'Coolify ist konfiguriert' : 'Coolify noch nicht vollständig konfiguriert'}</strong><span>Provider: ${escapeHtml(data.provider || 'none')} · Trigger: ${escapeHtml(data.trigger_mode || 'webhook')} · Docker-Socket: ${data.docker_socket_access ? 'vorhanden' : 'nicht freigegeben'}</span>`;
|
||
const action = document.getElementById('deployment-action-result');
|
||
const last = data.recent_runs?.[0] || data.production?.last_deployment;
|
||
if (action && last) action.innerHTML = `<strong>${escapeHtml(deploymentStateLabel(last.state))}</strong><span>${operationFormatDate(last.created_at || last.requested_at)} · ${escapeHtml(last.current_version || data.current_version || '?')} → ${escapeHtml(last.target_version || '?')} · Branch ${escapeHtml(last.github_branch || 'main')}</span>`;
|
||
renderProductionOverview(data);
|
||
renderDeploymentRuns(data);
|
||
window.VendooIcons?.render(document.getElementById('operations-updates-panel'));
|
||
}
|
||
|
||
async function loadDeploymentStatus() {
|
||
try { operationsState.deployment = await api('GET', '/api/admin/deployment'); renderDeploymentStatus(); }
|
||
catch (error) { const box = document.getElementById('deployment-status'); if (box) box.innerHTML = `<strong>Deploymentstatus konnte nicht geladen werden</strong><span>${escapeHtml(error.message)}</span>`; }
|
||
}
|
||
|
||
function deploymentSettingsPayload() {
|
||
return {
|
||
provider: document.getElementById('deployment-provider')?.value || 'none',
|
||
coolify_url: document.getElementById('deployment-coolify-url')?.value || '',
|
||
resource_uuid: document.getElementById('deployment-resource-uuid')?.value || '',
|
||
trigger_mode: document.getElementById('deployment-trigger-mode')?.value || 'webhook',
|
||
deploy_method: 'GET',
|
||
deploy_endpoint: '/api/v1/deploy?uuid={uuid}&force={force}',
|
||
deployment_status_endpoint: document.getElementById('deployment-status-endpoint')?.value || '/api/v1/deployments/{deployment_id}',
|
||
deployment_timeout_seconds: Number(document.getElementById('deployment-timeout-seconds')?.value || 900),
|
||
health_poll_seconds: Number(document.getElementById('deployment-poll-seconds')?.value || 15),
|
||
github_repository: document.getElementById('deployment-github-repository')?.value || 'Masterluke77/vendoo',
|
||
github_branch: document.getElementById('deployment-github-branch')?.value || 'main',
|
||
ghcr_image: 'ghcr.io/masterluke77/vendoo',
|
||
update_channel: document.getElementById('deployment-update-channel')?.value || 'stable',
|
||
backup_before_deploy: document.getElementById('deployment-backup-before')?.checked !== false,
|
||
backup_uploads: document.getElementById('deployment-backup-uploads')?.checked === true,
|
||
api_token: document.getElementById('deployment-api-token')?.value || '',
|
||
deploy_webhook_url: document.getElementById('deployment-webhook-url')?.value || '',
|
||
};
|
||
}
|
||
|
||
async function loadOperationsCenter() {
|
||
if (!document.getElementById('operations-center-title')) return;
|
||
try { operationsState.status = await api('GET', '/api/operations/status'); renderOperationsStatus(); } catch (error) { toast(error.message); }
|
||
await Promise.all([loadOperationsBackups(), loadOperationsExtensions(), loadDeploymentStatus()]);
|
||
}
|
||
|
||
function setOperationsTab(target) {
|
||
document.querySelectorAll('.operations-tab').forEach(button => button.classList.toggle('active', button.dataset.operationsTab === target));
|
||
document.querySelectorAll('.operations-panel').forEach(panel => panel.classList.toggle('active', panel.id === `operations-${target}-panel`));
|
||
}
|
||
|
||
function setupOperationsCenter() {
|
||
const hour = document.getElementById('operations-auto-hour');
|
||
if (hour && !hour.options.length) hour.innerHTML = Array.from({ length: 24 }, (_, index) => `<option value="${index}">${String(index).padStart(2, '0')}:00 Uhr</option>`).join('');
|
||
document.querySelectorAll('.operations-tab').forEach(button => button.addEventListener('click', () => setOperationsTab(button.dataset.operationsTab)));
|
||
document.getElementById('operations-refresh-btn')?.addEventListener('click', loadOperationsCenter);
|
||
document.getElementById('operations-extension-refresh')?.addEventListener('click', loadOperationsExtensions);
|
||
|
||
document.getElementById('operations-backup-create')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget;
|
||
button.disabled = true; const old = button.innerHTML; button.textContent = 'Backup wird erstellt und geprüft …';
|
||
try {
|
||
const result = await api('POST', '/api/operations/backups', {
|
||
label: document.getElementById('operations-backup-label')?.value || '',
|
||
include_uploads: document.getElementById('operations-backup-uploads')?.checked !== false,
|
||
include_config: document.getElementById('operations-backup-config')?.checked === true,
|
||
});
|
||
toast(`Backup verifiziert: ${result.filename}`);
|
||
document.getElementById('operations-backup-label').value = '';
|
||
await loadOperationsBackups();
|
||
} catch (error) { toast(error.message || 'Backup fehlgeschlagen.'); }
|
||
finally { button.disabled = false; button.innerHTML = old; window.VendooIcons?.render(button); }
|
||
});
|
||
|
||
document.getElementById('operations-settings-save')?.addEventListener('click', async () => {
|
||
try {
|
||
operationsState.status.settings = await api('PUT', '/api/operations/settings', {
|
||
auto_backup_enabled: document.getElementById('operations-auto-enabled')?.checked === true,
|
||
auto_backup_hour: Number(document.getElementById('operations-auto-hour')?.value || 3),
|
||
auto_backup_retention: Number(document.getElementById('operations-auto-retention')?.value || 7),
|
||
include_uploads_automatic: document.getElementById('operations-auto-uploads')?.checked !== false,
|
||
});
|
||
renderOperationsStatus(); toast('Backup-Zeitplan gespeichert.');
|
||
} catch (error) { toast(error.message); }
|
||
});
|
||
|
||
const restoreInput = document.getElementById('operations-restore-input');
|
||
restoreInput?.addEventListener('change', () => {
|
||
operationsState.restoreFile = restoreInput.files?.[0] || null;
|
||
document.getElementById('operations-restore-file').textContent = operationsState.restoreFile ? `${operationsState.restoreFile.name} · ${operationFormatBytes(operationsState.restoreFile.size)}` : 'Keine Datei ausgewählt.';
|
||
document.getElementById('operations-restore-stage').disabled = !operationsState.restoreFile;
|
||
});
|
||
document.getElementById('operations-restore-stage')?.addEventListener('click', async event => {
|
||
if (!operationsState.restoreFile || !confirm('Backup vollständig prüfen und Wiederherstellung vorbereiten? Es wird automatisch ein Sicherheitsbackup erstellt.')) return;
|
||
const button = event.currentTarget; button.disabled = true; button.textContent = 'Backup wird geprüft …';
|
||
try { const result = await operationsUploadRequest('/api/operations/restore/stage', 'backup', operationsState.restoreFile); toast(result.message); operationsState.restoreFile = null; restoreInput.value = ''; await loadOperationsCenter(); }
|
||
catch (error) { toast(error.message); }
|
||
finally { button.disabled = !operationsState.restoreFile; button.textContent = 'Wiederherstellung prüfen und vorbereiten'; }
|
||
});
|
||
|
||
document.getElementById('operations-update-url-save')?.addEventListener('click', async () => {
|
||
try { operationsState.status.settings = await api('PUT', '/api/operations/settings', { update_manifest_url: document.getElementById('operations-update-url')?.value || '' }); renderOperationsStatus(); toast('Update-Manifest-URL gespeichert.'); }
|
||
catch (error) { toast(error.message); }
|
||
});
|
||
document.getElementById('operations-update-check')?.addEventListener('click', async event => {
|
||
const resultBox = document.getElementById('operations-update-check-result'); const button = event.currentTarget; button.disabled = true; if (resultBox) resultBox.textContent = 'Update wird geprüft …';
|
||
try {
|
||
const result = await api('POST', '/api/operations/updates/check');
|
||
if (resultBox) resultBox.innerHTML = result.configured ? `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : 'Vendoo ist aktuell'}</strong><span>Installiert: ${escapeHtml(result.current_version)}${result.latest_version ? ` · Verfügbar: ${escapeHtml(result.latest_version)}` : ''}</span>` : `<strong>Keine Online-Prüfung konfiguriert</strong><span>${escapeHtml(result.message || '')}</span>`;
|
||
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Prüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
|
||
finally { button.disabled = false; }
|
||
});
|
||
|
||
const updateInput = document.getElementById('operations-update-input');
|
||
updateInput?.addEventListener('change', () => {
|
||
operationsState.updateFile = updateInput.files?.[0] || null;
|
||
document.getElementById('operations-update-file').textContent = operationsState.updateFile ? `${operationsState.updateFile.name} · ${operationFormatBytes(operationsState.updateFile.size)}` : 'Keine Datei ausgewählt.';
|
||
document.getElementById('operations-update-stage').disabled = !operationsState.updateFile;
|
||
});
|
||
document.getElementById('operations-update-stage')?.addEventListener('click', async event => {
|
||
if (!operationsState.updateFile || !confirm('Update prüfen, Sicherheitsbackup erzeugen und für den nächsten Setup-Lauf vorbereiten?')) return;
|
||
const button = event.currentTarget; button.disabled = true; button.textContent = 'Update wird geprüft …';
|
||
try { const result = await operationsUploadRequest('/api/operations/updates/stage', 'update', operationsState.updateFile); toast(result.message); operationsState.updateFile = null; updateInput.value = ''; await loadOperationsCenter(); }
|
||
catch (error) { toast(error.message); }
|
||
finally { button.disabled = !operationsState.updateFile; button.textContent = 'Update prüfen und vorbereiten'; }
|
||
});
|
||
|
||
document.getElementById('deployment-production-check')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-production-check-result');
|
||
button.disabled = true; if (resultBox) resultBox.textContent = 'Produktionsgrenzen werden geprüft …';
|
||
try {
|
||
const result = await api('POST', '/api/admin/deployment/production-check');
|
||
const details = (result.results || []).filter(item => item.status !== 'pass').map(item => `${item.title || item.name || item.id}: ${item.message || item.status}`).slice(0, 4).join(' · ');
|
||
if (resultBox) resultBox.innerHTML = `<strong>${result.overall_status === 'passed' ? 'Produktionscheck bestanden' : result.overall_status === 'warning' ? 'Produktionscheck mit Hinweisen' : 'Produktionscheck fehlgeschlagen'}</strong><span>${Number(result.passed_count || 0)} bestanden · ${Number(result.warning_count || 0)} Hinweise · ${Number(result.failed_count || 0)} Fehler${details ? ` · ${escapeHtml(details)}` : ''}</span>`;
|
||
await loadDeploymentStatus();
|
||
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Produktionscheck konnte nicht ausgeführt werden</strong><span>${escapeHtml(error.message)}</span>`; }
|
||
finally { button.disabled = false; }
|
||
});
|
||
|
||
document.getElementById('deployment-save')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget; button.disabled = true;
|
||
try { operationsState.deployment = await api('PUT', '/api/admin/deployment', deploymentSettingsPayload()); renderDeploymentStatus(); toast('Coolify- und GitHub-Konfiguration gespeichert.'); }
|
||
catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
});
|
||
document.getElementById('deployment-test')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-status'); button.disabled = true;
|
||
try { const result = await api('POST', '/api/admin/deployment/test'); if (resultBox) resultBox.innerHTML = `<strong>Coolify erreichbar</strong><span>Healthcheck HTTP ${escapeHtml(String(result.status || 200))}</span>`; }
|
||
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Coolify nicht erreichbar</strong><span>${escapeHtml(error.message)}</span>`; }
|
||
finally { button.disabled = false; }
|
||
});
|
||
document.getElementById('deployment-update-check')?.addEventListener('click', async event => {
|
||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true;
|
||
try {
|
||
const result = await api('POST', '/api/admin/deployment/updates/check');
|
||
const target = document.getElementById('deployment-target-version');
|
||
if (target && result.latest_version) target.value = result.latest_version;
|
||
if (resultBox) resultBox.innerHTML = `<strong>${result.update_available ? `Version ${escapeHtml(result.latest_version)} verfügbar` : result.configured ? 'Vendoo ist aktuell' : 'Keine Manifest-URL konfiguriert'}</strong><span>Installiert: ${escapeHtml(result.current_version || VENDOO_UI_BUILD)}${result.latest_version ? ` · Freigegeben: ${escapeHtml(result.latest_version)}` : ''}${result.message ? ` · ${escapeHtml(result.message)}` : ''}</span>`;
|
||
}
|
||
catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Updateprüfung fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; }
|
||
finally { button.disabled = false; }
|
||
});
|
||
document.getElementById('deployment-trigger')?.addEventListener('click', async event => {
|
||
const confirmation = prompt('Vor dem Deployment wird – sofern aktiviert – ein Backup erzeugt. Coolify zieht danach den konfigurierten GitHub-Branch. Zum Fortfahren exakt DEPLOY eingeben:');
|
||
if (confirmation !== 'DEPLOY') return;
|
||
const button = event.currentTarget; const resultBox = document.getElementById('deployment-action-result'); button.disabled = true; button.textContent = 'Deployment wird angefordert …';
|
||
try {
|
||
const targetVersion = document.getElementById('deployment-target-version')?.value || operationsState.deployment?.current_version || VENDOO_UI_BUILD;
|
||
const idempotencyKey = globalThis.crypto?.randomUUID?.() || `vendoo-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||
const result = await api('POST', '/api/admin/deployment/trigger', { confirmation, force: false, reason: 'update-center', target_version: targetVersion, idempotency_key: idempotencyKey });
|
||
if (resultBox) resultBox.innerHTML = `<strong>${result.reused ? 'Vorhandener Deployment-Auftrag erkannt' : 'Coolify-Deployment angenommen'}</strong><span>${escapeHtml(result.message || deploymentStateLabel(result.state))}${result.backup_id ? ` · Backup ${escapeHtml(String(result.backup_id).slice(0, 12))}` : ''}</span>`;
|
||
toast(result.reused ? 'Der gleiche Deployment-Auftrag war bereits vorhanden.' : 'Coolify-Deployment wurde angefordert.');
|
||
await loadDeploymentStatus();
|
||
} catch (error) { if (resultBox) resultBox.innerHTML = `<strong>Deployment fehlgeschlagen</strong><span>${escapeHtml(error.message)}</span>`; toast(error.message); }
|
||
finally { button.disabled = false; button.textContent = 'Coolify-Deployment starten'; }
|
||
});
|
||
}
|
||
|
||
// --- Admin ---
|
||
function setupAdmin() {
|
||
// Admin tab switching
|
||
document.querySelectorAll('.admin-tab').forEach(tab => {
|
||
tab.addEventListener('click', () => {
|
||
const target = tab.dataset.adminTab;
|
||
switchAdminTab(target);
|
||
if (target === 'users') loadAdminUsers();
|
||
if (target === 'roles') loadRoleMatrix();
|
||
if (target === 'sessions') loadAdminSessions();
|
||
if (target === 'smtp') loadSmtpSettings();
|
||
if (target === 'system') { loadSystemInfo(); loadOperationsCenter(); }
|
||
if (target === 'modules') globalThis.VendooModuleManager?.load?.();
|
||
if (target === 'security') loadSecurityCenter();
|
||
if (target === 'audit') loadAdminAudit();
|
||
});
|
||
});
|
||
|
||
document.getElementById('invite-user-btn')?.addEventListener('click', () => {
|
||
document.getElementById('invite-email').value = '';
|
||
document.getElementById('invite-name').value = '';
|
||
document.getElementById('invite-role').value = 'viewer';
|
||
document.getElementById('invite-result-wrap').classList.add('hidden');
|
||
document.getElementById('invite-result-password').value = '';
|
||
const mlRadio = document.querySelector('input[name="invite-method"][value="magic_link"]');
|
||
if (mlRadio) mlRadio.checked = true;
|
||
document.getElementById('invite-send-btn').textContent = 'Einladung senden';
|
||
document.getElementById('invite-send-btn').onclick = sendInvite;
|
||
document.getElementById('invite-modal').classList.remove('hidden');
|
||
});
|
||
|
||
document.getElementById('invite-copy-pw')?.addEventListener('click', () => {
|
||
const pw = document.getElementById('invite-result-password').value;
|
||
if (pw) { navigator.clipboard.writeText(pw); toast('Passwort kopiert!'); }
|
||
});
|
||
|
||
document.getElementById('invite-send-btn')?.addEventListener('click', sendInvite);
|
||
document.getElementById('edit-user-save-btn')?.addEventListener('click', saveUserEdit);
|
||
|
||
// SMTP
|
||
document.getElementById('smtp-save-btn')?.addEventListener('click', saveSmtpSettings);
|
||
document.getElementById('smtp-test-btn')?.addEventListener('click', testSmtpConnection);
|
||
document.getElementById('smtp-pass-toggle')?.addEventListener('click', () => {
|
||
const inp = document.getElementById('smtp-pass');
|
||
const isPw = inp.type === 'password';
|
||
inp.type = isPw ? 'text' : 'password';
|
||
document.getElementById('smtp-pass-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen';
|
||
});
|
||
|
||
// Edit user password toggle
|
||
document.getElementById('edit-user-pw-toggle')?.addEventListener('click', () => {
|
||
const inp = document.getElementById('edit-user-password');
|
||
const isPw = inp.type === 'password';
|
||
inp.type = isPw ? 'text' : 'password';
|
||
document.getElementById('edit-user-pw-toggle').textContent = isPw ? 'Verbergen' : 'Zeigen';
|
||
});
|
||
|
||
// System tab
|
||
document.getElementById('system-cleanup-btn')?.addEventListener('click', async () => {
|
||
try {
|
||
const res = await api('POST', '/api/admin/sessions/cleanup');
|
||
toast(res.message || 'Sessions bereinigt');
|
||
loadSystemInfo();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
});
|
||
|
||
document.getElementById('system-diagnostics-btn')?.addEventListener('click', runSystemDiagnostics);
|
||
document.getElementById('system-diagnostics-copy')?.addEventListener('click', copySystemDiagnostics);
|
||
document.getElementById('security-refresh-btn')?.addEventListener('click', loadSecurityCenter);
|
||
document.getElementById('audit-filter-btn')?.addEventListener('click', () => { auditState.page = 1; loadAdminAudit(); });
|
||
document.getElementById('audit-search')?.addEventListener('keydown', event => { if (event.key === 'Enter') { auditState.page = 1; loadAdminAudit(); } });
|
||
document.getElementById('audit-prev')?.addEventListener('click', () => { if (auditState.page > 1) { auditState.page -= 1; loadAdminAudit(); } });
|
||
document.getElementById('audit-next')?.addEventListener('click', () => { if (auditState.page < auditState.pages) { auditState.page += 1; loadAdminAudit(); } });
|
||
document.getElementById('audit-export-btn')?.addEventListener('click', exportAdminAudit);
|
||
|
||
setupOperationsCenter();
|
||
}
|
||
|
||
function switchAdminTab(target) {
|
||
document.querySelectorAll('.admin-tab').forEach(t => t.classList.toggle('active', t.dataset.adminTab === target));
|
||
document.querySelectorAll('.admin-panel').forEach(p => p.classList.remove('active'));
|
||
document.getElementById(`admin-${target}-panel`)?.classList.add('active');
|
||
}
|
||
|
||
function timeAgo(dateStr) {
|
||
if (!dateStr) return 'Nie';
|
||
const diff = Date.now() - new Date(dateStr + 'Z').getTime();
|
||
const min = Math.floor(diff / 60000);
|
||
if (min < 1) return 'Gerade eben';
|
||
if (min < 60) return `Vor ${min}m`;
|
||
const hrs = Math.floor(min / 60);
|
||
if (hrs < 24) return `Vor ${hrs}h`;
|
||
const days = Math.floor(hrs / 24);
|
||
return `Vor ${days}d`;
|
||
}
|
||
|
||
function getInitials(name, email) {
|
||
const src = name || email || '?';
|
||
return src.split(/[\s@]/).map(w => w[0]?.toUpperCase()).filter(Boolean).slice(0, 2).join('');
|
||
}
|
||
|
||
const ROLE_LABELS = { admin: 'Administrator', manager: 'Manager', editor: 'Editor', publisher: 'Publisher', viewer: 'Leser' };
|
||
let adminUsersCache = [];
|
||
|
||
async function loadRoleMatrix() {
|
||
const cards = document.getElementById('role-cards');
|
||
const matrix = document.getElementById('role-permission-matrix');
|
||
if (!cards || !matrix) return;
|
||
try {
|
||
const data = await api('GET', '/api/security/roles');
|
||
cards.innerHTML = data.roles.map(role => `<article class="role-card role-${escapeHtml(role.key)}"><header><span class="role-badge ${escapeHtml(role.key)}">${escapeHtml(role.label)}</span><strong>${role.permissions.length} Rechte</strong></header><p>${escapeHtml(role.description)}</p></article>`).join('');
|
||
const groups = [...new Set(data.permissions.map(permission => permission.group))];
|
||
matrix.innerHTML = groups.map(group => {
|
||
const permissions = data.permissions.filter(permission => permission.group === group);
|
||
return `<section class="role-matrix-group"><h4>${escapeHtml(group)}</h4><div class="role-matrix-table"><div class="role-matrix-head"><span>Berechtigung</span>${data.roles.map(role => `<strong>${escapeHtml(role.label)}</strong>`).join('')}</div>${permissions.map(permission => `<div class="role-matrix-row"><span>${escapeHtml(permission.label)}</span>${data.roles.map(role => `<i class="${role.permissions.includes(permission.key) ? 'is-allowed' : 'is-denied'}" title="${role.permissions.includes(permission.key) ? 'Erlaubt' : 'Nicht erlaubt'}">${role.permissions.includes(permission.key) ? '✓' : '—'}</i>`).join('')}</div>`).join('')}</div></section>`;
|
||
}).join('');
|
||
} catch (error) {
|
||
cards.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`;
|
||
matrix.innerHTML = '';
|
||
}
|
||
}
|
||
|
||
async function forceReleaseSecurityLock(type, id) {
|
||
if (!confirm('Bearbeitungssperre wirklich administrativ aufheben?')) return;
|
||
try { await api('DELETE', `/api/security/locks/${encodeURIComponent(type)}/${encodeURIComponent(id)}`); toast('Sperre aufgehoben'); loadSecurityCenter(); }
|
||
catch (error) { toast(error.message); }
|
||
}
|
||
|
||
async function loadSecurityCenter() {
|
||
const summary = document.getElementById('security-status-summary');
|
||
if (!summary) return;
|
||
summary.innerHTML = '<div class="empty-state">Sicherheitsstatus wird geladen …</div>';
|
||
try {
|
||
const status = await api('GET', '/api/security/status');
|
||
const secure = !status.remote_binding || status.secure_public_url;
|
||
summary.innerHTML = `<div class="security-score ${secure && !status.warnings.length ? 'is-good' : 'is-warning'}"><span data-vd-icon="${secure ? 'shield-check' : 'shield-alert'}"></span><div><strong>${secure && !status.warnings.length ? 'Sicherheitsbasis aktiv' : 'Konfiguration braucht Aufmerksamkeit'}</strong><p>Vendoo ${escapeHtml(status.version)} · Bind ${escapeHtml(status.bind_host)}:${status.port} · ${status.trust_proxy ? 'Reverse-Proxy vertraut' : 'Direkte Verbindung'}</p></div></div>`;
|
||
const warnings = document.getElementById('security-warning-list');
|
||
warnings.innerHTML = status.warnings.length ? status.warnings.map(message => `<div class="security-warning"><span data-vd-icon="triangle-alert"></span><p>${escapeHtml(message)}</p></div>`).join('') : '<div class="security-ok"><span data-vd-icon="badge-check"></span> Keine kritischen Konfigurationswarnungen erkannt.</div>';
|
||
document.getElementById('security-network-info').innerHTML = `<dl><dt>Bind-Adresse</dt><dd><code>${escapeHtml(status.bind_host)}</code></dd><dt>Öffentliche URL</dt><dd>${status.public_base_url ? `<code>${escapeHtml(status.public_base_url)}</code>` : 'Nicht gesetzt'}</dd><dt>HTTPS</dt><dd>${status.secure_public_url ? 'Aktiv' : 'Nicht erkannt'}</dd><dt>Erlaubte Hosts</dt><dd>${status.allowed_hosts.length ? status.allowed_hosts.map(escapeHtml).join(', ') : 'Nur lokale/private Hosts'}</dd><dt>Erlaubte Origins</dt><dd>${status.allowed_origins.length ? status.allowed_origins.map(escapeHtml).join(', ') : 'Nur Same-Origin und Extensions'}</dd></dl>`;
|
||
document.getElementById('security-session-info').innerHTML = `<dl><dt>Absolute Sessiondauer</dt><dd>${status.session_days} Tage</dd><dt>Inaktivitätslimit</dt><dd>${status.session_idle_minutes} Minuten</dd><dt>Rate-Limit erlaubt</dt><dd>${status.rate_limits.allowed}</dd><dt>Rate-Limit blockiert</dt><dd>${status.rate_limits.blocked}</dd><dt>Aktive Sperren</dt><dd>${status.active_locks.length}</dd></dl>`;
|
||
const secretInfo = document.getElementById('security-secret-info');
|
||
if (secretInfo) secretInfo.innerHTML = `<dl><dt>Provider</dt><dd>${escapeHtml(status.secrets.provider)}</dd><dt>Verschlüsselt</dt><dd>${status.secrets.encrypted}</dd><dt>Umgebungsvariablen</dt><dd>${status.secrets.environment}</dd><dt>Konfiguriert</dt><dd>${status.secrets.configured}</dd></dl><div class="security-secret-list">${status.secrets.secrets.filter(item => item.configured).map(item => `<span class="status-badge">${escapeHtml(item.label)} · ${escapeHtml(item.provider)}</span>`).join('') || '<span class="muted">Keine Secrets konfiguriert.</span>'}</div>`;
|
||
const requestInfo = document.getElementById('security-request-info');
|
||
if (requestInfo) requestInfo.innerHTML = `<dl><dt>Requests seit Start</dt><dd>${status.requests.total}</dd><dt>Aktiv</dt><dd>${status.requests.active}</dd><dt>Serverfehler</dt><dd>${status.requests.errors}</dd><dt>Sicherheitsblockaden</dt><dd>${status.requests.blocked}</dd><dt>Inline-Skripte</dt><dd>${status.csp.script_inline_allowed ? 'Erlaubt' : 'Blockiert'}</dd><dt>CSP</dt><dd>${status.csp.enforced ? 'Erzwungen' : 'Nur Bericht'}</dd></dl>`;
|
||
const locks = document.getElementById('security-lock-list');
|
||
locks.innerHTML = status.active_locks.length ? status.active_locks.map(lock => `<article class="security-lock-row"><div><strong>${escapeHtml(lock.resource_type)} #${escapeHtml(lock.resource_id)}</strong><p>${escapeHtml(lock.user_name || lock.user_email)} · bis ${new Date(lock.expires_at).toLocaleTimeString('de-DE')}</p></div><button type="button" class="small-btn danger-btn" data-force-lock-type="${escapeHtml(lock.resource_type)}" data-force-lock-id="${escapeHtml(lock.resource_id)}">Aufheben</button></article>`).join('') : '<div class="empty-state">Keine aktiven Bearbeitungssperren.</div>';
|
||
locks.querySelectorAll('[data-force-lock-type]').forEach(button => button.addEventListener('click', () => forceReleaseSecurityLock(button.dataset.forceLockType, button.dataset.forceLockId)));
|
||
globalThis.VendooIcons?.render?.();
|
||
} catch (error) { summary.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
|
||
}
|
||
|
||
const auditState = { page: 1, pages: 1, total: 0 };
|
||
|
||
function buildAuditQuery() {
|
||
const size = Number(document.getElementById('audit-page-size')?.value || 100);
|
||
const params = new URLSearchParams({ limit: String(size), offset: String((auditState.page - 1) * size) });
|
||
const values = {
|
||
q: document.getElementById('audit-search')?.value.trim(),
|
||
action: document.getElementById('audit-action-filter')?.value.trim(),
|
||
from: document.getElementById('audit-from')?.value,
|
||
to: document.getElementById('audit-to')?.value ? `${document.getElementById('audit-to').value} 23:59:59` : '',
|
||
};
|
||
for (const [key, value] of Object.entries(values)) if (value) params.set(key, value);
|
||
return params;
|
||
}
|
||
|
||
function exportAdminAudit() {
|
||
const params = buildAuditQuery();
|
||
params.delete('limit'); params.delete('offset');
|
||
window.location.href = `/api/admin/audit/export.csv?${params.toString()}`;
|
||
}
|
||
|
||
async function loadAdminInvitations() {
|
||
const container = document.getElementById('invitations-list');
|
||
if (!container) return;
|
||
try {
|
||
const result = await api('GET', '/api/admin/users/invitations');
|
||
const invitations = result.invitations || [];
|
||
if (!invitations.length) { container.innerHTML = '<div class="empty-state">Noch keine Einladungen vorhanden.</div>'; return; }
|
||
const statusLabel = status => ({ pending: 'Offen', expired: 'Abgelaufen', revoked_or_used: 'Verwendet oder widerrufen', revoked: 'Widerrufen' })[status] || status;
|
||
container.innerHTML = invitations.map(invitation => {
|
||
const actionable = ['pending', 'expired'].includes(invitation.status);
|
||
return `<article class="invitation-admin-row ${invitation.status === 'pending' ? 'is-pending' : ''}">
|
||
<div><strong>${escapeHtml(invitation.email)}</strong><span>${escapeHtml(ROLE_LABELS[invitation.role] || invitation.role || 'Leser')} · ${escapeHtml(statusLabel(invitation.status))}</span><small>Erstellt ${operationFormatDate(invitation.created_at)} · Ablauf ${operationFormatDate(invitation.expires_at)}</small></div>
|
||
${actionable ? `<div class="invitation-admin-actions"><button type="button" class="small-btn" data-invitation-resend="${invitation.id}" title="Einladung erneut senden"><span data-vd-icon="mail"></span></button>${invitation.status === 'pending' ? `<button type="button" class="small-btn danger-btn" data-invitation-revoke="${invitation.id}" title="Einladung widerrufen"><span data-vd-icon="ban"></span></button>` : ''}</div>` : ''}
|
||
</article>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-invitation-resend]').forEach(button => button.addEventListener('click', async () => {
|
||
button.disabled = true;
|
||
try {
|
||
const result = await api('POST', `/api/admin/users/invitations/${encodeURIComponent(button.dataset.invitationResend)}/resend`);
|
||
toast(result.consoleFallback ? 'Neue Einladung erstellt; Link wurde in der Server-Konsole ausgegeben.' : 'Einladung erneut gesendet.');
|
||
await loadAdminInvitations();
|
||
} catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
container.querySelectorAll('[data-invitation-revoke]').forEach(button => button.addEventListener('click', async () => {
|
||
if (!confirm('Diese Einladung sofort widerrufen? Der Link kann danach nicht mehr verwendet werden.')) return;
|
||
button.disabled = true;
|
||
try { await api('DELETE', `/api/admin/users/invitations/${encodeURIComponent(button.dataset.invitationRevoke)}`); toast('Einladung widerrufen.'); await loadAdminInvitations(); }
|
||
catch (error) { toast(error.message); }
|
||
finally { button.disabled = false; }
|
||
}));
|
||
globalThis.VendooIcons?.render?.(container);
|
||
} catch (error) { container.innerHTML = `<div class="empty-state">${escapeHtml(error.message)}</div>`; }
|
||
}
|
||
|
||
async function loadAdminUsers() {
|
||
try {
|
||
const users = await api('GET', '/api/admin/users');
|
||
adminUsersCache = Array.isArray(users) ? users : [];
|
||
const container = document.getElementById('users-list');
|
||
if (!container) return;
|
||
if (!adminUsersCache.length) { container.innerHTML = '<div class="empty-state">Keine User vorhanden</div>'; await loadAdminInvitations(); return; }
|
||
|
||
container.innerHTML = adminUsersCache.map(u => {
|
||
const initials = getInitials(u.name, u.email);
|
||
const isOnline = u.last_active_at && (Date.now() - new Date(`${u.last_active_at}Z`).getTime()) < 5 * 60 * 1000;
|
||
const dotClass = !u.active ? 'inactive' : isOnline ? 'online' : 'offline';
|
||
const lockedBadge = u.locked_until && Date.parse(u.locked_until) > Date.now() ? '<span class="pw-indicator no-pw">Vorübergehend gesperrt</span>' : '';
|
||
const pwBadge = u.has_password
|
||
? '<span class="pw-indicator has-pw">Passwort gesetzt</span>'
|
||
: '<span class="pw-indicator no-pw">Kein Passwort</span>';
|
||
return `<div class="user-card" data-user-card="${u.id}">
|
||
<div class="user-card-avatar" style="background:${escapeHtml(u.avatar_color || 'var(--accent)')}">${escapeHtml(initials)}</div>
|
||
<div class="user-card-info">
|
||
<div class="name">${escapeHtml(u.name || u.email)} <span class="role-badge ${escapeHtml(u.role)}">${escapeHtml(ROLE_LABELS[u.role] || u.role)}</span> ${pwBadge} ${lockedBadge}</div>
|
||
<div class="email">${escapeHtml(u.email)}</div>
|
||
<div class="meta"><span class="status-dot ${dotClass}"></span> ${u.active ? timeAgo(u.last_active_at) : 'Deaktiviert'} · Erstellt: ${new Date(`${u.created_at}Z`).toLocaleDateString('de-DE')}</div>
|
||
</div>
|
||
<div class="user-card-actions">
|
||
<button type="button" class="small-btn" data-user-edit="${u.id}" title="Bearbeiten" aria-label="${escapeHtml(u.name || u.email)} bearbeiten"><span data-vd-icon="pencil"></span></button>
|
||
<button type="button" class="small-btn" data-user-resend="${u.id}" title="Einladung erneut senden" aria-label="Einladung erneut senden"><span data-vd-icon="mail"></span></button>
|
||
${u.id !== currentUser?.id ? `<button type="button" class="small-btn danger-btn" data-user-delete="${u.id}" title="Löschen" aria-label="${escapeHtml(u.name || u.email)} löschen"><span data-vd-icon="trash-2"></span></button>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
|
||
container.querySelectorAll('[data-user-edit]').forEach(button => button.addEventListener('click', () => openEditUser(Number(button.dataset.userEdit))));
|
||
container.querySelectorAll('[data-user-resend]').forEach(button => button.addEventListener('click', () => resendInvite(Number(button.dataset.userResend))));
|
||
container.querySelectorAll('[data-user-delete]').forEach(button => button.addEventListener('click', () => {
|
||
const user = adminUsersCache.find(entry => Number(entry.id) === Number(button.dataset.userDelete));
|
||
if (user) confirmDeleteUser(user.id, user.email);
|
||
}));
|
||
globalThis.VendooIcons?.render?.(container);
|
||
await loadAdminInvitations();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function sendInvite() {
|
||
const email = document.getElementById('invite-email').value.trim();
|
||
const name = document.getElementById('invite-name').value.trim();
|
||
const role = document.getElementById('invite-role').value;
|
||
const method = document.querySelector('input[name="invite-method"]:checked')?.value || 'magic_link';
|
||
if (!email) return toast('E-Mail erforderlich');
|
||
|
||
try {
|
||
const result = await api('POST', '/api/admin/users/invite', { email, name, role, method });
|
||
|
||
if (method === 'auto_password' && result.password) {
|
||
// Show generated password
|
||
const resultWrap = document.getElementById('invite-result-wrap');
|
||
const pwInput = document.getElementById('invite-result-password');
|
||
pwInput.value = result.password;
|
||
resultWrap.classList.remove('hidden');
|
||
document.getElementById('invite-send-btn').textContent = 'Fertig';
|
||
document.getElementById('invite-send-btn').onclick = () => {
|
||
document.getElementById('invite-modal').classList.add('hidden');
|
||
resultWrap.classList.add('hidden');
|
||
document.getElementById('invite-send-btn').textContent = 'Einladung senden';
|
||
document.getElementById('invite-send-btn').onclick = sendInvite;
|
||
};
|
||
toast(result.consoleFallback ? 'Account erstellt — Zugangsdaten in Konsole' : 'Account erstellt & Zugangsdaten gesendet');
|
||
} else {
|
||
document.getElementById('invite-modal').classList.add('hidden');
|
||
if (result.consoleFallback) {
|
||
toast('Einladung erstellt — Link in Server-Konsole');
|
||
} else {
|
||
toast('Einladung gesendet an ' + email);
|
||
}
|
||
}
|
||
loadAdminUsers();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function resendInvite(userId) {
|
||
try {
|
||
const result = await api('POST', '/api/admin/users/resend-invite', { user_id: userId });
|
||
if (result.consoleFallback) {
|
||
toast('Link in Server-Konsole ausgegeben');
|
||
} else {
|
||
toast('Einladung erneut gesendet');
|
||
}
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function openEditUser(userId) {
|
||
try {
|
||
let user = adminUsersCache.find(entry => Number(entry.id) === Number(userId));
|
||
if (!user) {
|
||
const users = await api('GET', '/api/admin/users');
|
||
adminUsersCache = Array.isArray(users) ? users : [];
|
||
user = adminUsersCache.find(entry => Number(entry.id) === Number(userId));
|
||
}
|
||
if (!user) throw new Error('Benutzer wurde nicht gefunden.');
|
||
document.getElementById('edit-user-id').value = user.id;
|
||
const emailField = document.getElementById('edit-user-email');
|
||
if (emailField) emailField.value = user.email || '';
|
||
document.getElementById('edit-user-name').value = user.name || '';
|
||
document.getElementById('edit-user-role').value = user.role;
|
||
document.getElementById('edit-user-active').value = user.active ? '1' : '0';
|
||
document.getElementById('edit-user-password').value = '';
|
||
document.getElementById('edit-user-password').type = 'password';
|
||
document.getElementById('edit-user-pw-toggle').textContent = 'Zeigen';
|
||
|
||
// Password status
|
||
const pwStatus = document.getElementById('edit-user-pw-status');
|
||
if (user.has_password) {
|
||
pwStatus.textContent = 'Passwort gesetzt';
|
||
pwStatus.className = 'pw-status-badge has-pw';
|
||
} else {
|
||
pwStatus.textContent = 'Kein Passwort';
|
||
pwStatus.className = 'pw-status-badge no-pw';
|
||
}
|
||
|
||
// Login history
|
||
const historyEl = document.getElementById('edit-user-login-history');
|
||
historyEl.classList.add('hidden');
|
||
historyEl.innerHTML = '';
|
||
|
||
const showHistBtn = document.getElementById('edit-user-show-history');
|
||
showHistBtn.onclick = async () => {
|
||
if (!historyEl.classList.contains('hidden')) {
|
||
historyEl.classList.add('hidden');
|
||
showHistBtn.textContent = 'Login-Verlauf anzeigen';
|
||
return;
|
||
}
|
||
try {
|
||
const history = await api('GET', `/api/admin/users/${userId}/login-history`);
|
||
if (!history.length) {
|
||
historyEl.innerHTML = '<div style="padding:10px;font-size:12px;color:var(--text-muted);text-align:center">Keine Login-Einträge</div>';
|
||
} else {
|
||
historyEl.innerHTML = history.map(h => {
|
||
const dt = new Date(h.created_at + 'Z').toLocaleString('de-DE');
|
||
const statusClass = h.success ? 'success' : 'failed';
|
||
const statusText = h.success ? 'Erfolgreich' : 'Fehlgeschlagen';
|
||
return `<div class="login-history-item">
|
||
<span class="lh-time">${dt}</span>
|
||
<span class="lh-ip">${h.ip || '—'}</span>
|
||
<span class="lh-status ${statusClass}">${statusText}</span>
|
||
</div>`;
|
||
}).join('');
|
||
}
|
||
historyEl.classList.remove('hidden');
|
||
showHistBtn.textContent = 'Verlauf ausblenden';
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
};
|
||
|
||
document.getElementById('edit-user-modal').classList.remove('hidden');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function saveUserEdit() {
|
||
const id = Number(document.getElementById('edit-user-id').value);
|
||
const name = document.getElementById('edit-user-name').value.trim();
|
||
const role = document.getElementById('edit-user-role').value;
|
||
const active = Number(document.getElementById('edit-user-active').value);
|
||
const password = document.getElementById('edit-user-password').value;
|
||
const button = document.getElementById('edit-user-save-btn');
|
||
|
||
if (!Number.isInteger(id) || id <= 0) return toast('Ungültiger Benutzer. Bitte die Liste neu laden.');
|
||
if (!name) return toast('Bitte einen Namen eintragen.');
|
||
if (password && (password.length < 10 || !/[a-z]/.test(password) || !/[A-Z]/.test(password) || !/\d/.test(password) || !/[^A-Za-z0-9]/.test(password))) {
|
||
return toast('Passwort braucht mindestens 10 Zeichen, Groß-/Kleinbuchstaben, Zahl und Sonderzeichen');
|
||
}
|
||
|
||
const oldLabel = button?.textContent || 'Speichern';
|
||
if (button) { button.disabled = true; button.textContent = 'Wird gespeichert …'; }
|
||
try {
|
||
const updated = await api('PUT', `/api/admin/users/${id}`, { name, role, active });
|
||
if (password) await api('POST', `/api/admin/users/${id}/reset-password`, { password });
|
||
adminUsersCache = adminUsersCache.map(entry => Number(entry.id) === id ? { ...entry, ...updated, has_password: password ? 1 : entry.has_password } : entry);
|
||
toast(password ? 'Benutzer aktualisiert und Passwort zurückgesetzt.' : 'Benutzer aktualisiert.');
|
||
document.getElementById('edit-user-modal').classList.add('hidden');
|
||
await loadAdminUsers();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
finally { if (button) { button.disabled = false; button.textContent = oldLabel; } }
|
||
}
|
||
|
||
async function confirmDeleteUser(userId, email) {
|
||
if (!confirm(`User "${email}" wirklich löschen? Alle Sessions werden beendet.`)) return;
|
||
try {
|
||
await api('DELETE', `/api/admin/users/${userId}`);
|
||
toast('User gelöscht');
|
||
loadAdminUsers();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function loadAdminSessions() {
|
||
try {
|
||
const sessions = await api('GET', '/api/admin/sessions');
|
||
const container = document.getElementById('sessions-list');
|
||
if (!sessions.length) { container.innerHTML = '<div class="empty-state">Keine aktiven Sessions</div>'; return; }
|
||
|
||
container.innerHTML = sessions.map(s => {
|
||
const ua = parseUA(s.user_agent);
|
||
return `<div class="session-row">
|
||
<div class="session-info">
|
||
<strong>${s.name || s.email}</strong> <span class="role-badge ${s.role}">${s.role}</span>
|
||
<div class="session-ua">${ua}</div>
|
||
<div class="session-ip">IP: ${s.ip || 'unbekannt'}</div>
|
||
</div>
|
||
<div class="session-meta">
|
||
${timeAgo(s.created_at)}<br>
|
||
<small>Läuft ab: ${new Date(s.expires_at.endsWith('Z') ? s.expires_at : s.expires_at + 'Z').toLocaleDateString('de-DE')}</small>
|
||
</div>
|
||
<button class="small-btn" data-kill-session-id="${s.id}" style="color:var(--danger)" title="Session beenden">✕</button>
|
||
</div>`;
|
||
}).join('');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function killSession(sessionId) {
|
||
try {
|
||
await api('DELETE', `/api/admin/sessions/${sessionId}`);
|
||
toast('Session beendet');
|
||
loadAdminSessions();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function loadAdminAudit() {
|
||
try {
|
||
const params = buildAuditQuery();
|
||
const result = await api('GET', `/api/admin/audit?${params.toString()}`);
|
||
const logs = result.rows || [];
|
||
const size = Number(result.limit || 100);
|
||
auditState.total = Number(result.total || 0);
|
||
auditState.pages = Math.max(1, Math.ceil(auditState.total / size));
|
||
if (auditState.page > auditState.pages) auditState.page = auditState.pages;
|
||
const container = document.getElementById('audit-list');
|
||
document.getElementById('audit-summary').textContent = `${auditState.total.toLocaleString('de-DE')} protokollierte Ereignisse`;
|
||
document.getElementById('audit-page-info').textContent = `Seite ${auditState.page} von ${auditState.pages}`;
|
||
document.getElementById('audit-prev').disabled = auditState.page <= 1;
|
||
document.getElementById('audit-next').disabled = auditState.page >= auditState.pages;
|
||
if (!logs.length) { container.innerHTML = '<div class="empty-state">Keine passenden Audit-Einträge</div>'; return; }
|
||
container.innerHTML = logs.map(l => {
|
||
const actionParts = (l.action || '').split('.');
|
||
const category = actionParts[0] || 'other';
|
||
return `<div class="audit-row"><div class="audit-time">${new Date(String(l.created_at).replace(' ', 'T') + 'Z').toLocaleString('de-DE')}</div><div class="audit-user"><strong>${escapeHtml(l.user_email || 'System')}</strong><small>${escapeHtml(l.ip || '')}</small></div><div class="audit-action"><span class="audit-action-badge ${escapeHtml(category)}">${escapeHtml(l.action)}</span>${l.target_type ? `<small>${escapeHtml(l.target_type)} ${escapeHtml(l.target_id || '')}</small>` : ''}${l.details ? `<p>${escapeHtml(l.details)}</p>` : ''}</div></div>`;
|
||
}).join('');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
// --- SMTP Settings ---
|
||
async function loadSmtpSettings() {
|
||
try {
|
||
const smtp = await api('GET', '/api/admin/smtp');
|
||
document.getElementById('smtp-host').value = smtp.smtp_host || '';
|
||
document.getElementById('smtp-port').value = smtp.smtp_port || '587';
|
||
document.getElementById('smtp-user').value = smtp.smtp_user || '';
|
||
document.getElementById('smtp-pass').value = '';
|
||
document.getElementById('smtp-pass').placeholder = smtp.smtp_pass ? 'Gesetzt (leer = beibehalten)' : 'Passwort';
|
||
document.getElementById('smtp-from').value = smtp.smtp_from || '';
|
||
|
||
const dot = document.getElementById('smtp-status-dot');
|
||
const text = document.getElementById('smtp-status-text');
|
||
if (smtp.configured) {
|
||
dot.className = 'smtp-status-dot configured';
|
||
text.textContent = `SMTP konfiguriert (Quelle: ${smtp.source === 'db' ? 'Datenbank' : smtp.source === 'env' ? '.env Datei' : 'Nicht konfiguriert'})`;
|
||
} else {
|
||
dot.className = 'smtp-status-dot not-configured';
|
||
text.textContent = 'SMTP nicht konfiguriert — E-Mails werden in der Konsole ausgegeben';
|
||
}
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function saveSmtpSettings() {
|
||
const data = {
|
||
smtp_host: document.getElementById('smtp-host').value.trim(),
|
||
smtp_port: document.getElementById('smtp-port').value.trim(),
|
||
smtp_user: document.getElementById('smtp-user').value.trim(),
|
||
smtp_from: document.getElementById('smtp-from').value.trim(),
|
||
};
|
||
const pass = document.getElementById('smtp-pass').value;
|
||
if (pass) data.smtp_pass = pass;
|
||
|
||
try {
|
||
const btn = document.getElementById('smtp-save-btn');
|
||
btn.disabled = true; btn.textContent = 'Speichere...';
|
||
await api('PUT', '/api/admin/smtp', data);
|
||
toast('SMTP-Einstellungen gespeichert');
|
||
loadSmtpSettings();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
finally {
|
||
const btn = document.getElementById('smtp-save-btn');
|
||
btn.disabled = false; btn.textContent = 'SMTP speichern';
|
||
}
|
||
}
|
||
|
||
async function testSmtpConnection() {
|
||
const btn = document.getElementById('smtp-test-btn');
|
||
btn.disabled = true; btn.textContent = 'Sende Testmail...';
|
||
try {
|
||
const res = await api('POST', '/api/admin/smtp/test');
|
||
toast(res.message || 'Testmail gesendet!');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
finally { btn.disabled = false; btn.textContent = 'Verbindung testen'; }
|
||
}
|
||
|
||
|
||
// --- Batch Image Factory ---
|
||
let imageFactoryPollTimer = null;
|
||
let imageFactoryEditingProfileId = null;
|
||
|
||
function imageFactoryStatusMeta(status) {
|
||
return ({
|
||
queued: ['Wartend', 'queued'], running: ['Läuft', 'running'], paused: ['Pausiert', 'paused'],
|
||
completed: ['Fertig', 'completed'], partial: ['Teilweise fertig', 'partial'], failed: ['Fehlgeschlagen', 'failed'],
|
||
cancelled: ['Abgebrochen', 'cancelled'],
|
||
})[status] || [status || 'Unbekannt', 'queued'];
|
||
}
|
||
|
||
function updateImageFactorySelection() {
|
||
const sources = state.imageFactorySelectedSources.size;
|
||
const profiles = state.imageFactorySelectedProfiles.size;
|
||
const outputs = sources * profiles;
|
||
document.getElementById('image-factory-selection-count')?.replaceChildren(document.createTextNode(`${sources} Bilder · ${profiles} Profile`));
|
||
document.getElementById('image-factory-output-estimate')?.replaceChildren(document.createTextNode(outputs ? `${outputs} neue Bilddateien werden erzeugt` : 'Noch keine Ausgabe geplant'));
|
||
const submit = document.getElementById('image-factory-submit');
|
||
if (submit) submit.disabled = !sources || !profiles;
|
||
}
|
||
|
||
function renderImageFactorySources() {
|
||
const container = document.getElementById('image-factory-source-grid');
|
||
if (!container) return;
|
||
if (!state.imageFactorySources.length) {
|
||
container.innerHTML = '<div class="empty-state">Keine passenden Bilder gefunden. Passe Suche oder Quellenfilter an.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = state.imageFactorySources.map(item => {
|
||
const selected = state.imageFactorySelectedSources.has(item.image_path);
|
||
const title = item.title || item.prompt || item.image_path.split('/').pop();
|
||
return `<label class="image-factory-source-card${selected ? ' is-selected' : ''}">
|
||
<input type="checkbox" data-image-factory-source="${escapeHtml(item.image_path)}" ${selected ? 'checked' : ''}>
|
||
<span class="image-factory-source-check">${window.VendooIcons?.svg('check', { size: 13 }) || '✓'}</span>
|
||
<img src="${escapeHtml(item.public_url || photoUrl(item.image_path))}" alt="${escapeHtml(title)}" loading="lazy">
|
||
<span class="image-factory-source-caption"><strong>${escapeHtml(title)}</strong><small>${escapeHtml(MEDIA_SOURCE_LABELS[item.source] || item.source || 'Bild')}</small></span>
|
||
</label>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-image-factory-source]').forEach(input => input.addEventListener('change', () => {
|
||
if (input.checked) state.imageFactorySelectedSources.add(input.dataset.imageFactorySource);
|
||
else state.imageFactorySelectedSources.delete(input.dataset.imageFactorySource);
|
||
input.closest('.image-factory-source-card')?.classList.toggle('is-selected', input.checked);
|
||
updateImageFactorySelection();
|
||
}));
|
||
}
|
||
|
||
function profileDimensionLabel(profile) {
|
||
const resize = profile.operations?.resize || {};
|
||
const dimensions = resize.width || resize.height ? `${resize.width || 'auto'} × ${resize.height || 'auto'}` : 'Originalgröße';
|
||
return `${dimensions} · ${(profile.operations?.format || 'jpeg').toUpperCase()} · Q${profile.operations?.quality || 90}`;
|
||
}
|
||
|
||
function renderImageFactoryProfiles() {
|
||
const container = document.getElementById('image-factory-profile-grid');
|
||
if (!container) return;
|
||
if (!state.imageFactoryProfiles.length) {
|
||
container.innerHTML = '<div class="empty-state">Keine Produktionsprofile vorhanden.</div>';
|
||
return;
|
||
}
|
||
container.innerHTML = state.imageFactoryProfiles.map(profile => {
|
||
const selected = state.imageFactorySelectedProfiles.has(Number(profile.id));
|
||
return `<article class="image-factory-profile-card${selected ? ' is-selected' : ''}" data-image-factory-profile-card="${profile.id}">
|
||
<label class="image-factory-profile-select">
|
||
<input type="checkbox" data-image-factory-profile="${profile.id}" ${selected ? 'checked' : ''}>
|
||
<span>${window.VendooIcons?.svg('check', { size: 13 }) || '✓'}</span>
|
||
</label>
|
||
<div class="image-factory-profile-icon">${profile.is_builtin ? 'P' : 'E'}</div>
|
||
<div class="image-factory-profile-copy"><strong>${escapeHtml(profile.name)}</strong><p>${escapeHtml(profile.description || 'Eigenes Produktionsprofil')}</p><small>${escapeHtml(profileDimensionLabel(profile))}</small></div>
|
||
${profile.is_builtin ? '<span class="image-factory-profile-badge">Standard</span>' : `<div class="image-factory-profile-actions"><button type="button" data-image-factory-profile-edit="${profile.id}" title="Profil bearbeiten">Bearbeiten</button><button type="button" data-image-factory-profile-delete="${profile.id}" title="Profil löschen">Löschen</button></div>`}
|
||
</article>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-image-factory-profile]').forEach(input => input.addEventListener('change', () => {
|
||
const id = Number(input.dataset.imageFactoryProfile);
|
||
if (input.checked) state.imageFactorySelectedProfiles.add(id); else state.imageFactorySelectedProfiles.delete(id);
|
||
input.closest('.image-factory-profile-card')?.classList.toggle('is-selected', input.checked);
|
||
updateImageFactorySelection();
|
||
}));
|
||
container.querySelectorAll('[data-image-factory-profile-edit]').forEach(button => button.addEventListener('click', () => openImageFactoryProfileModal(Number(button.dataset.imageFactoryProfileEdit))));
|
||
container.querySelectorAll('[data-image-factory-profile-delete]').forEach(button => button.addEventListener('click', async () => {
|
||
const id = Number(button.dataset.imageFactoryProfileDelete);
|
||
if (!confirm('Dieses eigene Produktionsprofil löschen? Bestehende Aufträge und Bilder bleiben erhalten.')) return;
|
||
try { await api('DELETE', `/api/image-batch/profiles/${id}`); state.imageFactorySelectedProfiles.delete(id); await loadImageFactoryProfiles(); toast('Produktionsprofil gelöscht.'); }
|
||
catch (error) { toast(`Profil konnte nicht gelöscht werden: ${error.message}`); }
|
||
}));
|
||
}
|
||
|
||
async function loadImageFactorySources({ silent = false } = {}) {
|
||
const container = document.getElementById('image-factory-source-grid');
|
||
if (!silent && container) container.innerHTML = '<div class="empty-state">Bilder werden geladen …</div>';
|
||
try {
|
||
const params = new URLSearchParams({ page: '1', page_size: '96', source: document.getElementById('image-factory-source-filter')?.value || 'all', sort: 'newest' });
|
||
const search = document.getElementById('image-factory-source-search')?.value.trim();
|
||
if (search) params.set('q', search);
|
||
const result = await api('GET', `/api/media-library?${params}`);
|
||
state.imageFactorySources = result.items || [];
|
||
renderImageFactorySources();
|
||
} catch (error) {
|
||
if (container) container.innerHTML = `<div class="empty-state">Bilder konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadImageFactoryProfiles() {
|
||
try {
|
||
const result = await api('GET', '/api/image-batch/profiles');
|
||
state.imageFactoryProfiles = result.items || [];
|
||
if (!state.imageFactorySelectedProfiles.size && state.imageFactoryProfiles.length) state.imageFactorySelectedProfiles.add(Number(state.imageFactoryProfiles[0].id));
|
||
state.imageFactorySelectedProfiles = new Set([...state.imageFactorySelectedProfiles].filter(id => state.imageFactoryProfiles.some(profile => Number(profile.id) === Number(id))));
|
||
renderImageFactoryProfiles();
|
||
updateImageFactorySelection();
|
||
} catch (error) {
|
||
const container = document.getElementById('image-factory-profile-grid');
|
||
if (container) container.innerHTML = `<div class="empty-state">Profile konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderImageFactorySummary() {
|
||
const summary = state.imageFactorySummary || {};
|
||
document.querySelectorAll('[data-batch-summary]').forEach(node => { node.textContent = String(summary[node.dataset.batchSummary] || 0); });
|
||
}
|
||
|
||
function batchItemStatusLabel(status) {
|
||
return ({ queued: 'Wartend', running: 'Wird erzeugt', completed: 'Fertig', failed: 'Fehler', cancelled: 'Abgebrochen' })[status] || status;
|
||
}
|
||
|
||
function renderImageFactoryJobItems(job) {
|
||
if (!job.items) return '<div class="image-factory-job-loading">Details werden geladen …</div>';
|
||
return `<div class="image-factory-job-items">${job.items.map(item => `<article class="image-factory-job-item is-${escapeHtml(item.status)}">
|
||
<img src="${escapeHtml(photoUrl(item.output_path || item.source_path))}" alt="" loading="lazy">
|
||
<div><strong>${escapeHtml(item.profile_name)}</strong><span>${escapeHtml(item.source_path.split('/').pop())}</span>${item.error_message ? `<small>${escapeHtml(item.error_message)}</small>` : ''}</div>
|
||
<span class="image-factory-item-status">${escapeHtml(batchItemStatusLabel(item.status))}</span>
|
||
${['failed','cancelled'].includes(item.status) ? `<button type="button" class="vd-button compact" data-image-factory-retry-item="${escapeHtml(item.id)}" data-job-id="${escapeHtml(job.id)}">Erneut</button>` : ''}
|
||
${item.output_path ? `<a class="vd-button compact" href="${escapeHtml(photoUrl(item.output_path))}" download>Download</a>` : ''}
|
||
</article>`).join('')}</div>`;
|
||
}
|
||
|
||
function renderImageFactoryJobs() {
|
||
const container = document.getElementById('image-factory-jobs');
|
||
if (!container) return;
|
||
if (!state.imageFactoryJobs.length) {
|
||
container.innerHTML = '<div class="empty-state">Noch keine Produktionsaufträge. Wähle Bilder und Profile und starte die erste Produktion.</div>';
|
||
} else {
|
||
container.innerHTML = state.imageFactoryJobs.map(job => {
|
||
const [label, tone] = imageFactoryStatusMeta(job.status);
|
||
const terminal = ['completed','partial','failed','cancelled'].includes(job.status);
|
||
const expanded = state.imageFactoryExpanded.has(job.id);
|
||
const done = Number(job.completed_items || 0) + Number(job.failed_items || 0) + Number(job.cancelled_items || 0);
|
||
const total = Math.max(1, Number(job.total_items || 0));
|
||
const percent = Math.min(100, Math.round(done / total * 100));
|
||
return `<article class="image-factory-job is-${tone}${expanded ? ' is-expanded' : ''}" data-image-factory-job="${escapeHtml(job.id)}">
|
||
<button type="button" class="image-factory-job-head" data-image-factory-job-toggle="${escapeHtml(job.id)}" aria-expanded="${expanded}">
|
||
<span class="image-factory-job-chevron">›</span><span class="image-factory-job-title"><strong>${escapeHtml(job.name)}</strong><small>${job.source_count} Bilder · ${job.profile_count} Profile · ${formatFluxDate(job.created_at)}</small></span>
|
||
<span class="image-factory-job-count">${job.completed_items || 0}/${job.total_items || 0}</span><span class="image-factory-job-status">${label}</span>
|
||
</button>
|
||
<div class="image-factory-job-progress"><span style="width:${percent}%"></span></div>
|
||
<div class="image-factory-job-actions">
|
||
${['queued','running'].includes(job.status) ? `<button type="button" class="vd-button compact" data-image-factory-action="pause" data-job-id="${job.id}">Pausieren</button><button type="button" class="vd-button compact danger" data-image-factory-action="cancel" data-job-id="${job.id}">Abbrechen</button>` : ''}
|
||
${job.status === 'paused' ? `<button type="button" class="vd-button compact primary" data-image-factory-action="resume" data-job-id="${job.id}">Fortsetzen</button><button type="button" class="vd-button compact danger" data-image-factory-action="cancel" data-job-id="${job.id}">Abbrechen</button>` : ''}
|
||
${['failed','partial','cancelled'].includes(job.status) ? `<button type="button" class="vd-button compact" data-image-factory-action="retry" data-job-id="${job.id}">Fehler wiederholen</button>` : ''}
|
||
${Number(job.completed_items || 0) > 0 ? `<a class="vd-button compact" href="/api/image-batch/jobs/${encodeURIComponent(job.id)}/archive">Ergebnisse als ZIP</a>` : ''}
|
||
${terminal ? `<button type="button" class="vd-button compact danger" data-image-factory-action="delete" data-job-id="${job.id}">Auftrag löschen</button>` : ''}
|
||
</div>
|
||
<div class="image-factory-job-detail${expanded ? '' : ' hidden'}">${expanded ? renderImageFactoryJobItems(job) : ''}</div>
|
||
</article>`;
|
||
}).join('');
|
||
}
|
||
container.querySelectorAll('[data-image-factory-job-toggle]').forEach(button => button.addEventListener('click', async () => {
|
||
const id = button.dataset.imageFactoryJobToggle;
|
||
if (state.imageFactoryExpanded.has(id)) state.imageFactoryExpanded.delete(id); else state.imageFactoryExpanded.add(id);
|
||
const index = state.imageFactoryJobs.findIndex(job => job.id === id);
|
||
if (index >= 0 && state.imageFactoryExpanded.has(id) && !state.imageFactoryJobs[index].items) {
|
||
try { state.imageFactoryJobs[index] = await api('GET', `/api/image-batch/jobs/${encodeURIComponent(id)}`); } catch (error) { toast(error.message); }
|
||
}
|
||
renderImageFactoryJobs();
|
||
}));
|
||
container.querySelectorAll('[data-image-factory-action]').forEach(button => button.addEventListener('click', () => handleImageFactoryJobAction(button.dataset.imageFactoryAction, button.dataset.jobId)));
|
||
container.querySelectorAll('[data-image-factory-retry-item]').forEach(button => button.addEventListener('click', async () => {
|
||
try { await api('POST', `/api/image-batch/jobs/${encodeURIComponent(button.dataset.jobId)}/items/${encodeURIComponent(button.dataset.imageFactoryRetryItem)}/retry`); toast('Bildposition wurde erneut eingereiht.'); await loadImageFactoryJobs({ silent: true }); }
|
||
catch (error) { toast(`Wiederholung fehlgeschlagen: ${error.message}`); }
|
||
}));
|
||
const pagination = state.imageFactoryJobPagination || {};
|
||
document.getElementById('image-factory-job-page-info')?.replaceChildren(document.createTextNode(`Seite ${pagination.page || 1} von ${pagination.total_pages || 1}`));
|
||
const prev = document.getElementById('image-factory-job-prev'); const next = document.getElementById('image-factory-job-next');
|
||
if (prev) prev.disabled = Number(pagination.page || 1) <= 1;
|
||
if (next) next.disabled = Number(pagination.page || 1) >= Number(pagination.total_pages || 1);
|
||
}
|
||
|
||
async function loadImageFactoryJobs({ silent = false } = {}) {
|
||
const container = document.getElementById('image-factory-jobs');
|
||
if (!silent && container && !state.imageFactoryJobs.length) container.innerHTML = '<div class="empty-state">Produktionsaufträge werden geladen …</div>';
|
||
try {
|
||
const result = await api('GET', `/api/image-batch/jobs?page=${state.imageFactoryJobPage}&page_size=${state.imageFactoryJobPageSize}`);
|
||
const previous = new Map(state.imageFactoryJobs.map(job => [job.id, job]));
|
||
state.imageFactoryJobs = (result.items || []).map(job => state.imageFactoryExpanded.has(job.id) && previous.get(job.id)?.items ? { ...job, items: previous.get(job.id).items } : job);
|
||
const expandedJobs = state.imageFactoryJobs.filter(job => state.imageFactoryExpanded.has(job.id));
|
||
if (expandedJobs.length) {
|
||
const details = await Promise.all(expandedJobs.map(job => api('GET', `/api/image-batch/jobs/${encodeURIComponent(job.id)}`).catch(() => null)));
|
||
const detailMap = new Map(details.filter(Boolean).map(job => [job.id, job]));
|
||
state.imageFactoryJobs = state.imageFactoryJobs.map(job => detailMap.get(job.id) || job);
|
||
}
|
||
state.imageFactoryJobPagination = result.pagination || { page: 1, total_pages: 1, total: 0 };
|
||
state.imageFactoryJobPage = Number(state.imageFactoryJobPagination.page || 1);
|
||
state.imageFactorySummary = result.summary || null;
|
||
renderImageFactorySummary();
|
||
renderImageFactoryJobs();
|
||
const active = state.imageFactoryJobs.some(job => ['queued','running'].includes(job.status));
|
||
if (imageFactoryPollTimer) clearTimeout(imageFactoryPollTimer);
|
||
if (active && document.getElementById('image-factory')?.classList.contains('active')) imageFactoryPollTimer = setTimeout(() => loadImageFactoryJobs({ silent: true }), 1600);
|
||
} catch (error) {
|
||
if (container) container.innerHTML = `<div class="empty-state">Aufträge konnten nicht geladen werden: ${escapeHtml(error.message)}</div>`;
|
||
}
|
||
}
|
||
|
||
async function loadImageFactory() {
|
||
await Promise.all([loadImageFactoryProfiles(), loadImageFactorySources(), loadImageFactoryJobs()]);
|
||
}
|
||
|
||
async function submitImageFactoryJob() {
|
||
const button = document.getElementById('image-factory-submit');
|
||
const sourcePaths = [...state.imageFactorySelectedSources];
|
||
const profileIds = [...state.imageFactorySelectedProfiles];
|
||
if (!sourcePaths.length || !profileIds.length) return toast('Bitte mindestens ein Bild und ein Produktionsprofil auswählen.');
|
||
const original = button?.textContent || 'Bildproduktion starten';
|
||
if (button) { button.disabled = true; button.textContent = 'Auftrag wird angelegt …'; }
|
||
const watermarkEnabled = document.getElementById('image-factory-watermark')?.checked;
|
||
const overrides = {
|
||
remove_background: document.getElementById('image-factory-remove-bg')?.checked || false,
|
||
background_sensitivity: Number(document.getElementById('image-factory-bg-sensitivity')?.value || 45),
|
||
watermark: watermarkEnabled ? {
|
||
enabled: true,
|
||
text: document.getElementById('image-factory-watermark-text')?.value || 'Vendoo',
|
||
position: document.getElementById('image-factory-watermark-position')?.value || 'bottom-right',
|
||
opacity: Number(document.getElementById('image-factory-watermark-opacity')?.value || 40),
|
||
} : { enabled: false },
|
||
};
|
||
try {
|
||
const job = await api('POST', '/api/image-batch/jobs', { name: document.getElementById('image-factory-name')?.value.trim(), source_paths: sourcePaths, profile_ids: profileIds, overrides });
|
||
toast(`${job.total_items} Bildausgaben wurden in die Produktion eingereiht.`);
|
||
state.imageFactoryJobPage = 1;
|
||
state.imageFactoryExpanded.add(job.id);
|
||
await loadImageFactoryJobs({ silent: true });
|
||
document.getElementById('image-factory-jobs')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
} catch (error) { toast(`Bildproduktion konnte nicht gestartet werden: ${error.message}`); }
|
||
finally { if (button) { button.disabled = false; button.textContent = original; } updateImageFactorySelection(); }
|
||
}
|
||
|
||
async function handleImageFactoryJobAction(action, jobId) {
|
||
if (action === 'delete' && !confirm('Diesen abgeschlossenen Auftrag aus dem Job-Center löschen? Die erzeugten Bilder bleiben erhalten.')) return;
|
||
try {
|
||
if (action === 'delete') await api('DELETE', `/api/image-batch/jobs/${encodeURIComponent(jobId)}`);
|
||
else await api('POST', `/api/image-batch/jobs/${encodeURIComponent(jobId)}/${action}`);
|
||
toast(({ pause: 'Auftrag wird pausiert.', resume: 'Auftrag wird fortgesetzt.', cancel: 'Auftrag wird abgebrochen.', retry: 'Fehler wurden erneut eingereiht.', delete: 'Auftrag gelöscht.' })[action] || 'Auftrag aktualisiert.');
|
||
await loadImageFactoryJobs({ silent: true });
|
||
} catch (error) { toast(`Aktion fehlgeschlagen: ${error.message}`); }
|
||
}
|
||
|
||
function openImageFactoryProfileModal(profileId = null) {
|
||
imageFactoryEditingProfileId = profileId;
|
||
const profile = state.imageFactoryProfiles.find(item => Number(item.id) === Number(profileId));
|
||
const operations = profile?.operations || {};
|
||
const resize = operations.resize || {};
|
||
document.getElementById('image-factory-profile-title').textContent = profile ? 'Produktionsprofil bearbeiten' : 'Produktionsprofil anlegen';
|
||
document.getElementById('image-factory-profile-name').value = profile?.name || '';
|
||
document.getElementById('image-factory-profile-description').value = profile?.description || '';
|
||
document.getElementById('image-factory-profile-width').value = resize.width || 1600;
|
||
document.getElementById('image-factory-profile-height').value = resize.height || 1600;
|
||
document.getElementById('image-factory-profile-fit').value = resize.fit || 'contain';
|
||
document.getElementById('image-factory-profile-background').value = resize.background || '#ffffff';
|
||
document.getElementById('image-factory-profile-format').value = operations.format || 'jpeg';
|
||
document.getElementById('image-factory-profile-quality').value = operations.quality || 90;
|
||
document.getElementById('image-factory-profile-pattern').value = profile?.filename_pattern || '{name}-{profile}-{index}';
|
||
const modal = document.getElementById('image-factory-profile-modal');
|
||
modal?.classList.remove('hidden'); modal?.setAttribute('aria-hidden', 'false');
|
||
}
|
||
|
||
function closeImageFactoryProfileModal() {
|
||
const modal = document.getElementById('image-factory-profile-modal');
|
||
modal?.classList.add('hidden'); modal?.setAttribute('aria-hidden', 'true'); imageFactoryEditingProfileId = null;
|
||
}
|
||
|
||
async function saveImageFactoryProfile() {
|
||
const data = {
|
||
name: document.getElementById('image-factory-profile-name')?.value.trim(),
|
||
description: document.getElementById('image-factory-profile-description')?.value.trim(),
|
||
filename_pattern: document.getElementById('image-factory-profile-pattern')?.value.trim(),
|
||
operations: {
|
||
resize: {
|
||
width: Number(document.getElementById('image-factory-profile-width')?.value || 0),
|
||
height: Number(document.getElementById('image-factory-profile-height')?.value || 0),
|
||
fit: document.getElementById('image-factory-profile-fit')?.value || 'contain',
|
||
background: document.getElementById('image-factory-profile-background')?.value || '#ffffff',
|
||
},
|
||
format: document.getElementById('image-factory-profile-format')?.value || 'jpeg',
|
||
quality: Number(document.getElementById('image-factory-profile-quality')?.value || 90),
|
||
sharpness: 12,
|
||
},
|
||
};
|
||
if (!data.name) return toast('Bitte einen Namen für das Produktionsprofil eingeben.');
|
||
const button = document.getElementById('image-factory-profile-save');
|
||
if (button) button.disabled = true;
|
||
try {
|
||
const profile = imageFactoryEditingProfileId
|
||
? await api('PATCH', `/api/image-batch/profiles/${imageFactoryEditingProfileId}`, data)
|
||
: await api('POST', '/api/image-batch/profiles', data);
|
||
state.imageFactorySelectedProfiles.add(Number(profile.id));
|
||
closeImageFactoryProfileModal(); await loadImageFactoryProfiles(); toast('Produktionsprofil gespeichert.');
|
||
} catch (error) { toast(`Profil konnte nicht gespeichert werden: ${error.message}`); }
|
||
finally { if (button) button.disabled = false; }
|
||
}
|
||
|
||
function setupImageFactory() {
|
||
document.getElementById('image-factory-refresh')?.addEventListener('click', loadImageFactory);
|
||
document.getElementById('image-factory-jobs-refresh')?.addEventListener('click', () => loadImageFactoryJobs());
|
||
document.getElementById('image-factory-start')?.addEventListener('click', () => document.getElementById('image-factory-source-grid')?.scrollIntoView({ behavior: 'smooth', block: 'center' }));
|
||
document.getElementById('image-factory-submit')?.addEventListener('click', submitImageFactoryJob);
|
||
document.getElementById('image-factory-source-filter')?.addEventListener('change', () => loadImageFactorySources());
|
||
document.getElementById('image-factory-source-search')?.addEventListener('input', debounce(() => loadImageFactorySources({ silent: true }), 350));
|
||
document.getElementById('image-factory-select-visible')?.addEventListener('click', () => {
|
||
const visible = state.imageFactorySources.map(item => item.image_path);
|
||
const allSelected = visible.length && visible.every(path => state.imageFactorySelectedSources.has(path));
|
||
visible.forEach(path => allSelected ? state.imageFactorySelectedSources.delete(path) : state.imageFactorySelectedSources.add(path));
|
||
renderImageFactorySources(); updateImageFactorySelection();
|
||
});
|
||
document.getElementById('image-factory-clear-sources')?.addEventListener('click', () => { state.imageFactorySelectedSources.clear(); renderImageFactorySources(); updateImageFactorySelection(); });
|
||
document.getElementById('image-factory-bg-sensitivity')?.addEventListener('input', event => { document.getElementById('image-factory-bg-value').textContent = event.target.value; });
|
||
document.getElementById('image-factory-watermark-opacity')?.addEventListener('input', event => { document.getElementById('image-factory-watermark-value').textContent = `${event.target.value} %`; });
|
||
document.getElementById('image-factory-new-profile')?.addEventListener('click', () => openImageFactoryProfileModal());
|
||
document.getElementById('image-factory-profile-close')?.addEventListener('click', closeImageFactoryProfileModal);
|
||
document.getElementById('image-factory-profile-cancel')?.addEventListener('click', closeImageFactoryProfileModal);
|
||
document.getElementById('image-factory-profile-save')?.addEventListener('click', saveImageFactoryProfile);
|
||
document.getElementById('image-factory-profile-modal')?.addEventListener('click', event => { if (event.target.id === 'image-factory-profile-modal') closeImageFactoryProfileModal(); });
|
||
document.getElementById('image-factory-job-page-size')?.addEventListener('change', event => { state.imageFactoryJobPageSize = Number(event.target.value || 10); state.imageFactoryJobPage = 1; loadImageFactoryJobs(); });
|
||
document.getElementById('image-factory-job-prev')?.addEventListener('click', () => { if (state.imageFactoryJobPage > 1) { state.imageFactoryJobPage -= 1; loadImageFactoryJobs(); } });
|
||
document.getElementById('image-factory-job-next')?.addEventListener('click', () => { if (state.imageFactoryJobPage < Number(state.imageFactoryJobPagination.total_pages || 1)) { state.imageFactoryJobPage += 1; loadImageFactoryJobs(); } });
|
||
document.getElementById('media-send-to-factory')?.addEventListener('click', () => {
|
||
for (const imagePath of state.mediaSelected) state.imageFactorySelectedSources.add(imagePath);
|
||
switchToTab('image-factory');
|
||
toast(`${state.mediaSelected.size} Galeriebild(er) für die Bildproduktion übernommen.`);
|
||
});
|
||
}
|
||
|
||
|
||
// --- Article Quality Center ---
|
||
function qualityTone(report) {
|
||
if (report.blockers > 0) return 'blocked';
|
||
if (report.ready) return 'ready';
|
||
return 'review';
|
||
}
|
||
|
||
function qualityStatusLabel(report) {
|
||
if (report.blockers > 0) return `${report.blockers} Blocker`;
|
||
if (report.ready) return 'Bereit';
|
||
return 'Prüfen';
|
||
}
|
||
|
||
function qualityIssueIcon(severity) {
|
||
const names = { error: 'x', warning: 'triangle-alert', recommendation: 'sparkles' };
|
||
return window.VendooIcons?.svg(names[severity] || 'circle-help', { size: 18, strokeWidth: 2 }) || (severity === 'error' ? '!' : '·');
|
||
}
|
||
|
||
function renderQualityList() {
|
||
const container = document.getElementById('quality-list');
|
||
if (!container) return;
|
||
if (!state.qualityItems.length) {
|
||
container.innerHTML = '<div class="empty-state"><p>Keine Artikel entsprechen den gewählten Filtern.</p></div>';
|
||
return;
|
||
}
|
||
container.innerHTML = state.qualityItems.map(report => {
|
||
const item = report.listing || {};
|
||
const active = Number(state.qualityCurrent?.listing_id) === Number(report.listing_id);
|
||
const tone = qualityTone(report);
|
||
return `<button type="button" class="quality-list-item is-${tone}${active ? ' is-active' : ''}" data-quality-listing-id="${report.listing_id}">
|
||
<span class="quality-list-thumb">${item.photo ? `<img src="${escapeHtml(photoUrl(item.photo))}" alt="">` : '<span>□</span>'}</span>
|
||
<span class="quality-list-copy"><strong>${escapeHtml(item.title || 'Unbenannter Artikel')}</strong><small>${escapeHtml(item.sku || 'Ohne SKU')} · ${escapeHtml(platformLabel(item.platform))}</small><span>${report.issues?.[0] ? escapeHtml(report.issues[0].title) : 'Keine offenen Hinweise'}</span></span>
|
||
<span class="quality-list-score is-${String(report.grade || 'E').toLowerCase()}"><b>${report.score}</b><em>${escapeHtml(report.grade || 'E')}</em></span>
|
||
<span class="quality-list-state">${escapeHtml(qualityStatusLabel(report))}</span>
|
||
</button>`;
|
||
}).join('');
|
||
container.querySelectorAll('[data-quality-listing-id]').forEach(button => button.addEventListener('click', () => loadQualityDetail(button.dataset.qualityListingId)));
|
||
}
|
||
|
||
function renderQualitySummary() {
|
||
const summary = state.qualitySummary || {};
|
||
const values = {
|
||
'quality-summary-average': summary.average_score ?? 0,
|
||
'quality-summary-ready': summary.ready ?? 0,
|
||
'quality-summary-blocked': summary.blocked ?? 0,
|
||
'quality-summary-total': summary.total ?? 0,
|
||
};
|
||
Object.entries(values).forEach(([id, value]) => { const el = document.getElementById(id); if (el) el.textContent = value; });
|
||
}
|
||
|
||
function renderQualityPagination() {
|
||
const pagination = state.qualityPagination || {};
|
||
const page = Number(pagination.page || 1); const totalPages = Number(pagination.total_pages || 1); const total = Number(pagination.total || 0);
|
||
const start = total ? (page - 1) * Number(pagination.page_size || state.qualityPageSize) + 1 : 0;
|
||
const end = Math.min(total, page * Number(pagination.page_size || state.qualityPageSize));
|
||
const info = document.getElementById('quality-page-info'); if (info) info.textContent = `Seite ${page} von ${totalPages}`;
|
||
const range = document.getElementById('quality-list-range'); if (range) range.textContent = total ? `${start}–${end} von ${total}` : '0 Artikel';
|
||
const prev = document.getElementById('quality-prev'); const next = document.getElementById('quality-next');
|
||
if (prev) prev.disabled = page <= 1; if (next) next.disabled = page >= totalPages;
|
||
}
|
||
|
||
async function loadQualityCenter({ silent = false } = {}) {
|
||
const container = document.getElementById('quality-list');
|
||
if (!silent && container) container.innerHTML = '<div class="quality-loading"><span></span><span></span><span></span><p>Artikelqualität wird geladen …</p></div>';
|
||
const params = new URLSearchParams({
|
||
q: document.getElementById('quality-search')?.value.trim() || '',
|
||
status: document.getElementById('quality-status')?.value || 'all',
|
||
grade: document.getElementById('quality-grade')?.value || 'all',
|
||
page: String(state.qualityPage),
|
||
page_size: String(state.qualityPageSize),
|
||
});
|
||
try {
|
||
const result = await api('GET', `/api/quality-center?${params}`);
|
||
state.qualityItems = result.items || [];
|
||
state.qualityPagination = result.pagination || { page: 1, total_pages: 1, total: 0 };
|
||
state.qualitySummary = result.summary || {};
|
||
renderQualitySummary(); renderQualityPagination(); renderQualityList();
|
||
if (!state.qualityCurrent && state.qualityItems[0]) loadQualityDetail(state.qualityItems[0].listing_id, { quiet: true });
|
||
} catch (error) {
|
||
if (container) container.innerHTML = `<div class="empty-state"><p>Quality Center konnte nicht geladen werden: ${escapeHtml(error.message)}</p></div>`;
|
||
}
|
||
}
|
||
|
||
function renderQualityIssues(report) {
|
||
const container = document.getElementById('quality-issues');
|
||
if (!container) return;
|
||
const filter = state.qualityIssueFilter;
|
||
const issues = (report.issues || []).filter(item => filter === 'all' || item.severity === filter);
|
||
if (!issues.length) {
|
||
container.innerHTML = '<div class="quality-all-clear"><strong>Keine Punkte in diesem Bereich</strong><span>Der Artikel erfüllt die aktuell geprüften Qualitätsregeln.</span></div>';
|
||
return;
|
||
}
|
||
container.innerHTML = issues.map(item => `<article class="quality-issue is-${escapeHtml(item.severity)}">
|
||
<span class="quality-issue-icon">${qualityIssueIcon(item.severity)}</span>
|
||
<div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail || '')}</p><small>${escapeHtml(item.category || '')}${item.field ? ` · Feld: ${escapeHtml(item.field)}` : ''}</small></div>
|
||
<span class="quality-issue-points">−${Number(item.points || 0)}</span>
|
||
</article>`).join('');
|
||
}
|
||
|
||
function renderQualityDetail(report) {
|
||
state.qualityCurrent = report;
|
||
document.getElementById('quality-detail-empty')?.classList.add('hidden');
|
||
document.getElementById('quality-detail-content')?.classList.remove('hidden');
|
||
const title = document.getElementById('quality-detail-title'); if (title) title.textContent = report.listing?.title || 'Unbenannter Artikel';
|
||
const platform = document.getElementById('quality-detail-platform'); if (platform) platform.textContent = `${report.platform?.name || platformLabel(report.listing?.platform)} · Note ${report.grade}`;
|
||
const meta = document.getElementById('quality-detail-meta'); if (meta) meta.textContent = `${report.listing?.sku || 'Ohne SKU'} · ${report.facts?.photo_count || 0} Bilder · ${report.facts?.title_length || 0} Titelzeichen`;
|
||
const score = document.getElementById('quality-detail-score'); if (score) score.textContent = report.score;
|
||
const ring = document.getElementById('quality-score-ring'); if (ring) { ring.dataset.grade = report.grade || 'E'; ring.style.setProperty('--quality-score', `${report.score * 3.6}deg`); }
|
||
const readiness = document.getElementById('quality-readiness');
|
||
if (readiness) readiness.innerHTML = `<strong>${report.ready ? 'Bereit zur Veröffentlichung' : report.blockers ? 'Veröffentlichung blockiert' : 'Überarbeitung empfohlen'}</strong><span>${report.blockers || 0} Blocker · ${report.warnings || 0} Warnungen · ${report.recommendations || 0} Empfehlungen</span>`;
|
||
const finance = report.financials || {};
|
||
const financials = document.getElementById('quality-financials');
|
||
if (financials) financials.innerHTML = `<div><span>Preis</span><strong>${formatCurrency(finance.price || report.listing?.price || 0)}</strong></div><div><span>Gebühren</span><strong>${formatCurrency(finance.fees || 0)}</strong></div><div><span>Geschätzter Erlös</span><strong>${formatCurrency(finance.estimated_net || 0)}</strong></div>`;
|
||
renderQualityIssues(report);
|
||
const duplicates = document.getElementById('quality-duplicates');
|
||
if (duplicates) duplicates.innerHTML = report.duplicates?.length ? report.duplicates.map(item => `<button type="button" data-quality-duplicate-id="${item.id}"><strong>${escapeHtml(item.title || 'Artikel')}</strong><span>${escapeHtml(item.sku || 'Ohne SKU')} · ${item.same_sku ? 'gleiche SKU' : `${item.similarity}% ähnlich`}</span></button>`).join('') : '<p class="quality-empty-note">Keine auffälligen Duplikate gefunden.</p>';
|
||
duplicates?.querySelectorAll('[data-quality-duplicate-id]').forEach(button => button.addEventListener('click', () => showDetail(button.dataset.qualityDuplicateId)));
|
||
const images = document.getElementById('quality-images');
|
||
if (images) images.innerHTML = report.images?.length ? report.images.map(item => `<article class="quality-image-check"><span>${item.missing || item.unreadable ? '!' : '✓'}</span><div><strong>${escapeHtml(item.path || 'Bild')}</strong><small>${item.width ? `${item.width} × ${item.height} px · ${item.megapixels} MP · ${escapeHtml(item.format || '')}` : item.missing ? 'Datei fehlt' : 'Nicht lesbar'}</small></div></article>`).join('') : '<p class="quality-empty-note">Für eine vollständige Bildprüfung bitte „Neu analysieren“ verwenden.</p>';
|
||
const history = document.getElementById('quality-history');
|
||
if (history) history.innerHTML = report.history?.length ? report.history.map(item => `<div class="quality-history-item"><span>${new Date(String(item.analyzed_at || '').replace(' ', 'T') + 'Z').toLocaleString('de-DE')}</span><strong>${item.score}</strong><em>Note ${item.grade}</em></div>`).join('') : '<p class="quality-empty-note">Noch kein gespeicherter Verlauf.</p>';
|
||
const delta = document.getElementById('quality-score-delta');
|
||
if (delta) { const value = Number(report.comparison?.delta || 0); delta.textContent = value ? `${value > 0 ? '+' : ''}${value} seit letzter Prüfung` : 'Noch kein Vergleich'; delta.dataset.tone = value > 0 ? 'up' : value < 0 ? 'down' : 'neutral'; }
|
||
renderQualityList();
|
||
}
|
||
|
||
async function loadQualityDetail(listingId, { quiet = false } = {}) {
|
||
const panel = document.getElementById('quality-detail-content');
|
||
if (!quiet && panel) panel.classList.add('is-loading');
|
||
try {
|
||
const report = await api('GET', `/api/quality-center/${listingId}`, null, { timeoutMs: 20000 });
|
||
renderQualityDetail(report);
|
||
} catch (error) { toast(`Qualitätsprüfung fehlgeschlagen: ${error.message}`); }
|
||
finally { panel?.classList.remove('is-loading'); }
|
||
}
|
||
|
||
async function runQualityAnalysis(listingId = state.qualityCurrent?.listing_id) {
|
||
if (!listingId) return;
|
||
const button = document.getElementById('quality-run-analysis'); const original = button?.textContent;
|
||
if (button) { button.disabled = true; button.textContent = 'Analyse läuft …'; }
|
||
try {
|
||
const report = await api('POST', `/api/quality-center/${listingId}/analyze`, {}, { timeoutMs: 30000 });
|
||
renderQualityDetail(report); await loadQualityCenter({ silent: true }); toast(`Qualitätsanalyse abgeschlossen: ${report.score}/100`);
|
||
} catch (error) { toast(`Analyse fehlgeschlagen: ${error.message}`); }
|
||
finally { if (button) { button.disabled = false; button.textContent = original || 'Neu analysieren'; } }
|
||
}
|
||
|
||
async function analyzeVisibleQualityItems() {
|
||
const ids = state.qualityItems.map(item => item.listing_id).slice(0, 25);
|
||
if (!ids.length) return toast('Keine sichtbaren Artikel vorhanden.');
|
||
const button = document.getElementById('quality-analyze-visible'); const original = button?.textContent;
|
||
if (button) button.disabled = true;
|
||
let done = 0; let failed = 0;
|
||
for (const id of ids) {
|
||
if (button) button.textContent = `${done + 1} / ${ids.length} prüfen`;
|
||
try { await api('POST', `/api/quality-center/${id}/analyze`, {}, { timeoutMs: 30000 }); done += 1; } catch { failed += 1; }
|
||
}
|
||
if (button) { button.disabled = false; button.textContent = original || 'Sichtbare prüfen'; }
|
||
await loadQualityCenter({ silent: true });
|
||
if (state.qualityCurrent?.listing_id) await loadQualityDetail(state.qualityCurrent.listing_id, { quiet: true });
|
||
toast(`${done} Artikel geprüft${failed ? ` · ${failed} fehlgeschlagen` : ''}.`);
|
||
}
|
||
|
||
function openQualityAiModal() {
|
||
if (!state.qualityCurrent?.listing_id) return;
|
||
state.qualityAiDraft = null;
|
||
const modal = document.getElementById('quality-ai-modal'); modal?.classList.remove('hidden');
|
||
document.getElementById('quality-ai-compare')?.classList.add('hidden');
|
||
document.getElementById('quality-ai-apply')?.classList.add('hidden');
|
||
const status = document.getElementById('quality-ai-status'); if (status) status.textContent = 'Wähle die Felder und erstelle einen Vorschlag.';
|
||
const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = '';
|
||
}
|
||
|
||
function closeQualityAiModal() { document.getElementById('quality-ai-modal')?.classList.add('hidden'); state.qualityAiDraft = null; }
|
||
|
||
async function generateQualityAiSuggestion() {
|
||
const fields = [['title','quality-ai-field-title'],['description','quality-ai-field-description'],['tags','quality-ai-field-tags']].filter(([, id]) => document.getElementById(id)?.checked).map(([field]) => field);
|
||
if (!fields.length) return toast('Bitte mindestens ein Feld auswählen.');
|
||
const button = document.getElementById('quality-ai-generate'); const status = document.getElementById('quality-ai-status');
|
||
if (button) button.disabled = true; if (status) status.textContent = 'Der aktive AI-Provider erstellt sichere Vorschläge …';
|
||
try {
|
||
const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/ai-suggestions`, { fields, mode: document.getElementById('quality-ai-mode')?.value || 'balanced' }, { timeoutMs: 60000 });
|
||
state.qualityAiDraft = result;
|
||
const original = result.original || {}; const suggestions = result.suggestions || {};
|
||
document.getElementById('quality-ai-original-title').value = original.title || '';
|
||
document.getElementById('quality-ai-original-description').value = original.description || '';
|
||
document.getElementById('quality-ai-original-tags').value = (original.tags || []).join(', ');
|
||
document.getElementById('quality-ai-suggested-title').value = suggestions.title ?? original.title ?? '';
|
||
document.getElementById('quality-ai-suggested-description').value = suggestions.description ?? original.description ?? '';
|
||
document.getElementById('quality-ai-suggested-tags').value = (suggestions.tags ?? original.tags ?? []).join(', ');
|
||
document.getElementById('quality-ai-compare')?.classList.remove('hidden'); document.getElementById('quality-ai-apply')?.classList.remove('hidden');
|
||
const reasons = document.getElementById('quality-ai-reasons'); if (reasons) reasons.innerHTML = (suggestions.reasons || []).map(reason => `<span>${escapeHtml(reason)}</span>`).join('');
|
||
if (status) status.textContent = `Vorschlag über ${result.provider} erstellt. Prüfe und bearbeite ihn vor dem Übernehmen.`;
|
||
} catch (error) { if (status) status.textContent = `AI-Vorschlag fehlgeschlagen: ${error.message}`; }
|
||
finally { if (button) button.disabled = false; }
|
||
}
|
||
|
||
async function applyQualityAiSuggestion() {
|
||
if (!state.qualityAiDraft || !state.qualityCurrent?.listing_id) return;
|
||
const payload = {};
|
||
if (document.getElementById('quality-ai-field-title')?.checked) payload.title = document.getElementById('quality-ai-suggested-title').value.trim();
|
||
if (document.getElementById('quality-ai-field-description')?.checked) payload.description = document.getElementById('quality-ai-suggested-description').value.trim();
|
||
if (document.getElementById('quality-ai-field-tags')?.checked) payload.tags = document.getElementById('quality-ai-suggested-tags').value.split(',').map(value => value.trim()).filter(Boolean);
|
||
const button = document.getElementById('quality-ai-apply'); if (button) button.disabled = true;
|
||
try {
|
||
const result = await api('POST', `/api/quality-center/${state.qualityCurrent.listing_id}/apply-suggestions`, payload, { timeoutMs: 30000 });
|
||
closeQualityAiModal(); renderQualityDetail(result.report); await loadQualityCenter({ silent: true }); toast('Bestätigte Verbesserungen wurden gespeichert.');
|
||
} catch (error) { toast(`Verbesserungen konnten nicht gespeichert werden: ${error.message}`); }
|
||
finally { if (button) button.disabled = false; }
|
||
}
|
||
|
||
function setupQualityCenter() {
|
||
document.getElementById('quality-refresh')?.addEventListener('click', () => loadQualityCenter());
|
||
document.getElementById('quality-analyze-visible')?.addEventListener('click', analyzeVisibleQualityItems);
|
||
document.getElementById('quality-search')?.addEventListener('input', debounce(() => { state.qualityPage = 1; loadQualityCenter({ silent: true }); }, 300));
|
||
document.getElementById('quality-status')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); });
|
||
document.getElementById('quality-grade')?.addEventListener('change', () => { state.qualityPage = 1; loadQualityCenter(); });
|
||
document.getElementById('quality-page-size')?.addEventListener('change', event => { state.qualityPageSize = Number(event.target.value || 10); state.qualityPage = 1; loadQualityCenter(); });
|
||
document.getElementById('quality-prev')?.addEventListener('click', () => { if (state.qualityPage > 1) { state.qualityPage -= 1; loadQualityCenter(); } });
|
||
document.getElementById('quality-next')?.addEventListener('click', () => { if (state.qualityPage < Number(state.qualityPagination.total_pages || 1)) { state.qualityPage += 1; loadQualityCenter(); } });
|
||
document.getElementById('quality-run-analysis')?.addEventListener('click', () => runQualityAnalysis());
|
||
document.getElementById('quality-open-article')?.addEventListener('click', () => state.qualityCurrent?.listing_id && showDetail(state.qualityCurrent.listing_id));
|
||
document.getElementById('quality-open-ai')?.addEventListener('click', openQualityAiModal);
|
||
document.querySelectorAll('[data-quality-issue-filter]').forEach(button => button.addEventListener('click', () => {
|
||
state.qualityIssueFilter = button.dataset.qualityIssueFilter || 'all';
|
||
document.querySelectorAll('[data-quality-issue-filter]').forEach(item => item.classList.toggle('active', item === button));
|
||
if (state.qualityCurrent) renderQualityIssues(state.qualityCurrent);
|
||
}));
|
||
document.getElementById('quality-ai-close')?.addEventListener('click', closeQualityAiModal);
|
||
document.getElementById('quality-ai-cancel')?.addEventListener('click', closeQualityAiModal);
|
||
document.getElementById('quality-ai-generate')?.addEventListener('click', generateQualityAiSuggestion);
|
||
document.getElementById('quality-ai-apply')?.addEventListener('click', applyQualityAiSuggestion);
|
||
document.getElementById('quality-ai-modal')?.addEventListener('click', event => { if (event.target.id === 'quality-ai-modal') closeQualityAiModal(); });
|
||
}
|
||
|
||
// --- System Diagnostics ---
|
||
function diagnosticTone(status) {
|
||
return status === 'ok' ? 'ok' : status === 'error' ? 'error' : status === 'warning' ? 'warning' : 'optional';
|
||
}
|
||
|
||
function diagnosticIcon(status) {
|
||
const name = status === 'ok' ? 'shield-check' : status === 'error' ? 'x' : 'circle-help';
|
||
return window.VendooIcons?.svg(name, { size: 18, strokeWidth: 2 }) || '';
|
||
}
|
||
|
||
function renderSystemDiagnostics(report) {
|
||
const summary = document.getElementById('system-diagnostics-summary');
|
||
const grid = document.getElementById('system-diagnostics-grid');
|
||
const copy = document.getElementById('system-diagnostics-copy');
|
||
if (!summary || !grid) return;
|
||
const errors = report.checks?.filter(item => item.status === 'error').length || 0;
|
||
const warnings = report.checks?.filter(item => ['warning', 'optional'].includes(item.status)).length || 0;
|
||
summary.className = `diagnostics-summary ${errors ? 'is-error' : warnings ? 'is-warning' : 'is-ok'}`;
|
||
summary.innerHTML = `<strong>${errors ? `${errors} kritische Prüfung(en) fehlgeschlagen` : 'Vendoo-Kernsystem ist betriebsbereit'}</strong><span>${warnings ? `${warnings} Hinweis(e) · ` : ''}${report.checks?.length || 0} Prüfungen in ${report.duration_ms || 0} ms · Build ${escapeHtml(report.version || VENDOO_UI_BUILD)}</span>`;
|
||
grid.innerHTML = (report.checks || []).map(check => `
|
||
<article class="diagnostic-card is-${diagnosticTone(check.status)}">
|
||
<span class="diagnostic-card-icon">${diagnosticIcon(check.status)}</span>
|
||
<div><strong>${escapeHtml(check.label)}</strong><p>${escapeHtml(check.detail || '')}</p></div>
|
||
<span class="diagnostic-card-status">${check.status === 'ok' ? 'OK' : check.status === 'error' ? 'Fehler' : check.status === 'warning' ? 'Hinweis' : 'Optional'}</span>
|
||
</article>`).join('');
|
||
if (copy) copy.disabled = false;
|
||
}
|
||
|
||
async function runSystemDiagnostics() {
|
||
const button = document.getElementById('system-diagnostics-btn');
|
||
const summary = document.getElementById('system-diagnostics-summary');
|
||
const grid = document.getElementById('system-diagnostics-grid');
|
||
const original = button?.textContent || 'Systemprüfung starten';
|
||
if (button) { button.disabled = true; button.textContent = 'Prüfung läuft …'; }
|
||
if (summary) { summary.className = 'diagnostics-summary is-running'; summary.textContent = 'Datenbank, Dateisystem, UI, Extensions und optionale Dienste werden geprüft …'; }
|
||
if (grid) grid.innerHTML = '<div class="diagnostics-loading"><span></span><span></span><span></span></div>';
|
||
try {
|
||
const report = await api('GET', '/api/admin/diagnostics', null, { retry: false, timeoutMs: 15000 });
|
||
clientDiagnostics.lastSystemReport = report;
|
||
renderSystemDiagnostics(report);
|
||
toast(report.ok ? 'Systemprüfung erfolgreich.' : 'Systemprüfung hat kritische Fehler gefunden.');
|
||
} catch (error) {
|
||
if (summary) { summary.className = 'diagnostics-summary is-error'; summary.textContent = `Systemprüfung fehlgeschlagen: ${error.message}`; }
|
||
if (grid) grid.innerHTML = '';
|
||
toast(`Systemprüfung fehlgeschlagen: ${error.message}`);
|
||
} finally {
|
||
if (button) { button.disabled = false; button.textContent = original; }
|
||
}
|
||
}
|
||
|
||
async function copySystemDiagnostics() {
|
||
const report = clientDiagnostics.lastSystemReport;
|
||
if (!report) return toast('Bitte zuerst eine Systemprüfung starten.');
|
||
const text = [
|
||
`Vendoo Diagnose ${report.version || VENDOO_UI_BUILD}`,
|
||
`Erstellt: ${report.generated_at || new Date().toISOString()}`,
|
||
`Runtime: ${report.runtime?.node || '?'} · ${report.runtime?.platform || '?'} ${report.runtime?.arch || ''}`,
|
||
'',
|
||
...(report.checks || []).map(check => `[${String(check.status).toUpperCase()}] ${check.label}: ${check.detail}`),
|
||
'',
|
||
`Client-Fehler (letzte ${clientDiagnostics.incidents.length}):`,
|
||
...clientDiagnostics.incidents.slice(0, 10).map(item => `${item.at} · ${item.type} · ${item.view} · ${item.message} · ${item.requestId || ''}`),
|
||
].join('\n');
|
||
try {
|
||
await navigator.clipboard.writeText(text);
|
||
toast('Diagnose in die Zwischenablage kopiert.');
|
||
} catch {
|
||
const area = document.createElement('textarea');
|
||
area.value = text; document.body.append(area); area.select(); document.execCommand('copy'); area.remove();
|
||
toast('Diagnose kopiert.');
|
||
}
|
||
}
|
||
|
||
// --- System Info ---
|
||
async function loadSystemInfo() {
|
||
try {
|
||
const sys = await api('GET', '/api/admin/system');
|
||
document.getElementById('system-info-cards').innerHTML = `
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Server Uptime</div>
|
||
<div class="sys-value">${sys.uptime}</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Benutzer</div>
|
||
<div class="sys-value accent">${sys.total_users}</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Artikel</div>
|
||
<div class="sys-value accent">${sys.total_listings}</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Aktive Sessions</div>
|
||
<div class="sys-value">${sys.active_sessions}</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Datenbankgröße</div>
|
||
<div class="sys-value">${sys.db_size}</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Node.js</div>
|
||
<div class="sys-value" style="font-size:16px">${sys.node_version}</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Speicher (RAM)</div>
|
||
<div class="sys-value">${sys.memory_mb} MB</div>
|
||
</div>
|
||
<div class="system-info-card">
|
||
<div class="sys-label">Plattform</div>
|
||
<div class="sys-value" style="font-size:16px">${sys.platform}</div>
|
||
</div>
|
||
`;
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function loadMySessions() {
|
||
try {
|
||
const sessions = await api('GET', '/api/my/sessions');
|
||
const container = document.getElementById('my-sessions-list');
|
||
container.innerHTML = sessions.length ? sessions.map(s => {
|
||
const ua = parseUA(s.user_agent);
|
||
return `<div class="session-row">
|
||
<div class="session-info">
|
||
<div class="session-ua">${ua}</div>
|
||
<div class="session-ip">IP: ${s.ip || 'unbekannt'}</div>
|
||
</div>
|
||
<div class="session-meta">${timeAgo(s.created_at)}</div>
|
||
<button class="small-btn" data-end-my-session-id="${s.id}" style="color:var(--danger)">Beenden</button>
|
||
</div>`;
|
||
}).join('') : '<div class="empty-state">Keine aktiven Sessions</div>';
|
||
document.getElementById('my-sessions-modal').classList.remove('hidden');
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
async function endMySession(sessionId) {
|
||
try {
|
||
await api('DELETE', `/api/my/sessions/${sessionId}`);
|
||
toast('Session beendet');
|
||
loadMySessions();
|
||
} catch (err) { toast('Fehler: ' + err.message); }
|
||
}
|
||
|
||
function parseUA(ua) {
|
||
if (!ua) return 'Unbekannt';
|
||
if (ua.includes('Chrome')) return 'Chrome';
|
||
if (ua.includes('Firefox')) return 'Firefox';
|
||
if (ua.includes('Safari')) return 'Safari';
|
||
if (ua.includes('Edge')) return 'Edge';
|
||
return ua.substring(0, 40);
|
||
}
|
||
|
||
init().catch(error => {
|
||
recordClientIncident('startup', error?.message || String(error));
|
||
setConnectionState(false, `Vendoo konnte nicht vollständig gestartet werden: ${error?.message || 'Unbekannter Fehler'}`);
|
||
console.error('[Vendoo] Initialisierung fehlgeschlagen', error);
|
||
});
|
||
|
||
// --- Vendoo 1.29.0 Publishing Hardening UI ---
|
||
publishState.jobsData = { items: [], pagination: { page: 1, page_size: 10, total: 0, total_pages: 1 }, summary: {} };
|
||
publishState.jobPage = 1;
|
||
publishState.jobPageSize = 10;
|
||
publishState.jobStatus = '';
|
||
publishState.jobPlatform = '';
|
||
publishState.jobSearch = '';
|
||
publishState.jobSelected = new Set();
|
||
publishState.publishEvents = [];
|
||
publishState.capabilities = null;
|
||
|
||
async function loadPublishingQueueData({ render = true } = {}) {
|
||
const params = new URLSearchParams({ page: String(publishState.jobPage), page_size: String(publishState.jobPageSize) });
|
||
if (publishState.jobStatus) params.set('status', publishState.jobStatus);
|
||
if (publishState.jobPlatform) params.set('platform', publishState.jobPlatform);
|
||
if (publishState.jobSearch) params.set('q', publishState.jobSearch);
|
||
const [jobs, events, capabilities] = await Promise.all([
|
||
publishSafeGet(`/api/publishing/jobs?${params}`, { items: [], pagination: { page: 1, page_size: publishState.jobPageSize, total: 0, total_pages: 1 }, summary: {} }, 2),
|
||
publishSafeGet('/api/publishing/events?limit=120', [], 2),
|
||
publishSafeGet('/api/publishing/capabilities', null, 2),
|
||
]);
|
||
publishState.jobsData = jobs;
|
||
publishState.publishEvents = Array.isArray(events) ? events : [];
|
||
publishState.capabilities = capabilities;
|
||
const visible = new Set((jobs.items || []).map(item => item.id));
|
||
publishState.jobSelected = new Set([...publishState.jobSelected].filter(id => visible.has(id)));
|
||
if (render) {
|
||
renderPublishWorkspace();
|
||
renderPublishStatusRail();
|
||
}
|
||
}
|
||
|
||
async function loadPublishList() {
|
||
await loadPublishListLegacy();
|
||
try { await loadPublishingQueueData(); }
|
||
catch (error) { console.warn('[Vendoo] Publishing-Queue konnte nicht geladen werden', error); }
|
||
}
|
||
|
||
function setPublishView(view) {
|
||
publishState.view = ['smart', 'direct', 'queue', 'errors', 'scheduled'].includes(view) ? view : 'smart';
|
||
document.querySelectorAll('[data-publish-view]').forEach(button => {
|
||
const active = button.dataset.publishView === publishState.view;
|
||
button.classList.toggle('active', active);
|
||
button.setAttribute('aria-selected', String(active));
|
||
});
|
||
renderPublishWorkspace();
|
||
}
|
||
|
||
function renderPublishWorkspace() {
|
||
if (publishState.view === 'queue') { renderQueueWorkspace(); return; }
|
||
if (publishState.view === 'errors') { renderPublishingErrorsWorkspace(); return; }
|
||
if (publishState.view === 'scheduled') { renderScheduledWorkspace(); return; }
|
||
const listing = selectedPublishListing();
|
||
const workspace = document.getElementById('publish-workspace');
|
||
if (!workspace) return;
|
||
if (!listing) {
|
||
workspace.innerHTML = `<div class="publish-workspace-empty"><span>↗</span><h3>Artikel auswählen</h3><p>Wähle links einen Artikel, um ihn für die Veröffentlichung vorzubereiten.</p></div>`;
|
||
return;
|
||
}
|
||
if (publishState.view === 'direct') renderDirectPublishWorkspace(listing);
|
||
else renderSmartCopyLauncher(listing);
|
||
}
|
||
|
||
function publishingStatusLabel(status) {
|
||
return ({ queued: 'Wartend', running: 'In Bearbeitung', awaiting_user: 'Wartet auf Extension', retry_wait: 'Wiederholung geplant', published: 'Veröffentlicht', drafted: 'Entwurf erstellt', failed: 'Fehlgeschlagen', cancelled: 'Abgebrochen', blocked: 'Blockiert', ended: 'Beendet' })[status] || status || 'Unbekannt';
|
||
}
|
||
function publishingActionLabel(action) {
|
||
return ({ publish: 'Veröffentlichen', update: 'Aktualisieren', end: 'Beenden', sync: 'Status abgleichen' })[action] || action;
|
||
}
|
||
function publishingModeLabel(mode) { return mode === 'api' ? 'Offizielle API' : 'Browser-Extension'; }
|
||
function publishingCan(job, action) {
|
||
return Boolean(publishState.capabilities?.platforms?.[job.platform]?.[action]);
|
||
}
|
||
function publishingDate(value) {
|
||
if (!value) return '—';
|
||
const date = new Date(String(value).replace(' ', 'T') + (String(value).includes('Z') ? '' : 'Z'));
|
||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString('de-DE', { dateStyle: 'short', timeStyle: 'short' });
|
||
}
|
||
|
||
function renderQueueWorkspace() {
|
||
const workspace = document.getElementById('publish-workspace');
|
||
const data = publishState.jobsData || { items: [], pagination: {}, summary: {} };
|
||
const items = data.items || [];
|
||
const page = data.pagination?.page || 1;
|
||
const pages = data.pagination?.total_pages || 1;
|
||
const total = data.pagination?.total || 0;
|
||
const selectedCount = publishState.jobSelected.size;
|
||
const platformOptions = ['', 'ebay-de', 'etsy', 'vinted', 'ebay-ka'].map(value => `<option value="${value}" ${publishState.jobPlatform === value ? 'selected' : ''}>${value ? escapeHtml(platformLabel(value)) : 'Alle Plattformen'}</option>`).join('');
|
||
const statusOptions = ['', 'queued', 'running', 'awaiting_user', 'retry_wait', 'published', 'drafted', 'failed', 'cancelled', 'ended'].map(value => `<option value="${value}" ${publishState.jobStatus === value ? 'selected' : ''}>${value ? escapeHtml(publishingStatusLabel(value)) : 'Alle Status'}</option>`).join('');
|
||
|
||
workspace.innerHTML = `<header class="publish-operation-header publishing-hardened-header">
|
||
<div><span class="publish-panel-kicker">Zuverlässige Verarbeitung</span><h3>Publishing-Queue</h3><p>${total} Aufträge · Schutz vor Doppelveröffentlichung · automatische Wiederholungen</p></div>
|
||
<div><button class="vd-button" type="button" data-publishing-refresh>Aktualisieren</button><button class="vd-button primary" type="button" data-publishing-add ${publishState.selected.size || publishState.selectedId ? '' : 'disabled'}>Auswahl einreihen</button></div>
|
||
</header>
|
||
<div class="publishing-toolbar">
|
||
<label class="publish-search-field"><span aria-hidden="true">⌕</span><input type="search" data-publishing-search value="${escapeHtml(publishState.jobSearch)}" placeholder="Titel, SKU, externe ID oder Fehler …"></label>
|
||
<select data-publishing-platform>${platformOptions}</select>
|
||
<select data-publishing-status>${statusOptions}</select>
|
||
<select data-publishing-size><option value="5" ${publishState.jobPageSize === 5 ? 'selected' : ''}>5 / Seite</option><option value="10" ${publishState.jobPageSize === 10 ? 'selected' : ''}>10 / Seite</option><option value="15" ${publishState.jobPageSize === 15 ? 'selected' : ''}>15 / Seite</option></select>
|
||
</div>
|
||
<div class="publishing-selection-bar"><label><input type="checkbox" data-publishing-select-page ${items.length && items.every(item => publishState.jobSelected.has(item.id)) ? 'checked' : ''}> Seite auswählen</label><span>${selectedCount} ausgewählt</span><button class="vd-button compact danger" type="button" data-publishing-delete-selected ${selectedCount ? '' : 'disabled'}>Ausgewählte löschen</button></div>
|
||
<div class="publish-operation-list publishing-job-list">${items.length ? items.map(renderPublishingJobRow).join('') : '<div class="publish-workspace-empty compact"><span>✓</span><h3>Keine Aufträge</h3><p>Wähle Artikel aus und füge sie zur Publishing-Queue hinzu.</p></div>'}</div>
|
||
<footer class="publishing-pagination"><span>Seite ${page} von ${pages}</span><div><button class="vd-button compact" data-publishing-prev ${page <= 1 ? 'disabled' : ''}>Zurück</button><button class="vd-button compact" data-publishing-next ${page >= pages ? 'disabled' : ''}>Weiter</button></div></footer>`;
|
||
|
||
workspace.querySelector('[data-publishing-refresh]')?.addEventListener('click', () => loadPublishingQueueData());
|
||
workspace.querySelector('[data-publishing-add]')?.addEventListener('click', batchEbayAdd);
|
||
workspace.querySelector('[data-publishing-search]')?.addEventListener('input', debounce(event => { publishState.jobSearch = event.target.value.trim(); publishState.jobPage = 1; loadPublishingQueueData(); }, 280));
|
||
workspace.querySelector('[data-publishing-platform]')?.addEventListener('change', event => { publishState.jobPlatform = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); });
|
||
workspace.querySelector('[data-publishing-status]')?.addEventListener('change', event => { publishState.jobStatus = event.target.value; publishState.jobPage = 1; loadPublishingQueueData(); });
|
||
workspace.querySelector('[data-publishing-size]')?.addEventListener('change', event => { publishState.jobPageSize = Number(event.target.value); publishState.jobPage = 1; loadPublishingQueueData(); });
|
||
workspace.querySelector('[data-publishing-prev]')?.addEventListener('click', () => { publishState.jobPage = Math.max(1, page - 1); loadPublishingQueueData(); });
|
||
workspace.querySelector('[data-publishing-next]')?.addEventListener('click', () => { publishState.jobPage = Math.min(pages, page + 1); loadPublishingQueueData(); });
|
||
workspace.querySelector('[data-publishing-select-page]')?.addEventListener('change', event => {
|
||
for (const item of items) event.target.checked ? publishState.jobSelected.add(item.id) : publishState.jobSelected.delete(item.id);
|
||
renderQueueWorkspace();
|
||
});
|
||
workspace.querySelectorAll('[data-publishing-check]').forEach(input => input.addEventListener('change', event => {
|
||
event.target.checked ? publishState.jobSelected.add(event.target.dataset.publishingCheck) : publishState.jobSelected.delete(event.target.dataset.publishingCheck);
|
||
renderQueueWorkspace();
|
||
}));
|
||
workspace.querySelector('[data-publishing-delete-selected]')?.addEventListener('click', deleteSelectedPublishingJobs);
|
||
workspace.querySelectorAll('[data-publishing-action]').forEach(button => button.addEventListener('click', () => runPublishingJobAction(button.dataset.publishingId, button.dataset.publishingAction)));
|
||
}
|
||
|
||
function renderPublishingJobRow(job) {
|
||
const canCancel = ['queued', 'running', 'awaiting_user', 'retry_wait'].includes(job.status);
|
||
const canRetry = ['failed', 'blocked', 'cancelled'].includes(job.status);
|
||
const canDelete = ['published', 'drafted', 'failed', 'cancelled', 'ended', 'blocked'].includes(job.status);
|
||
const isLive = job.status === 'published';
|
||
return `<article class="publish-operation-row publishing-job-row status-${escapeHtml(job.status)}">
|
||
<label class="publishing-job-check"><input type="checkbox" data-publishing-check="${job.id}" ${publishState.jobSelected.has(job.id) ? 'checked' : ''}></label>
|
||
<div class="publish-operation-product"><span class="publish-operation-mark">${escapeHtml(String(job.sku || 'VD').slice(-2))}</span><div><strong>${escapeHtml(job.title || 'Ohne Titel')}</strong><small>${escapeHtml(job.sku || '')} · ${escapeHtml(platformLabel(job.platform))}</small></div></div>
|
||
<div class="publish-operation-meta"><span>Aktion</span><b>${escapeHtml(publishingActionLabel(job.action))}</b><small>${escapeHtml(publishingModeLabel(job.mode))}</small></div>
|
||
<div class="publish-operation-meta"><span>Status</span><b class="operation-status status-${escapeHtml(job.status)}">${escapeHtml(publishingStatusLabel(job.status))}</b><small>${escapeHtml(publishingDate(job.updated_at))}</small></div>
|
||
<div class="publish-operation-meta"><span>Versuche</span><b>${Number(job.retries)}/${Number(job.max_retries)}</b><small>${job.next_attempt_at ? `Nächster: ${escapeHtml(publishingDate(job.next_attempt_at))}` : ''}</small></div>
|
||
<div class="publish-operation-error">${job.error_message ? `<span title="${escapeHtml(job.error_message)}">${escapeHtml(job.error_message)}</span>` : job.external_id ? `<span>Externe ID: ${escapeHtml(job.external_id)}</span>` : '<span>Keine Fehler</span>'}</div>
|
||
<div class="publish-operation-actions publishing-job-actions">
|
||
${job.external_url ? `<a class="icon-button" href="${escapeHtml(job.external_url)}" target="_blank" rel="noopener" title="Extern öffnen">↗</a>` : ''}
|
||
${isLive && publishingCan(job, 'sync') ? `<button class="icon-button" data-publishing-action="sync" data-publishing-id="${job.id}" title="Status abgleichen">⟳</button>` : ''}
|
||
${isLive && publishingCan(job, 'update') ? `<button class="icon-button" data-publishing-action="update" data-publishing-id="${job.id}" title="Angebot aktualisieren">✎</button>` : ''}
|
||
${isLive && publishingCan(job, 'end') ? `<button class="icon-button danger" data-publishing-action="end" data-publishing-id="${job.id}" title="Angebot beenden">■</button>` : ''}
|
||
${canRetry ? `<button class="icon-button" data-publishing-action="retry" data-publishing-id="${job.id}" title="Wiederholen">↻</button>` : ''}
|
||
${canCancel ? `<button class="icon-button danger" data-publishing-action="cancel" data-publishing-id="${job.id}" title="Abbrechen">×</button>` : ''}
|
||
${canDelete ? `<button class="icon-button danger" data-publishing-action="delete" data-publishing-id="${job.id}" title="Protokoll löschen">⌫</button>` : ''}
|
||
</div>
|
||
</article>`;
|
||
}
|
||
|
||
async function runPublishingJobAction(id, action) {
|
||
try {
|
||
if (action === 'retry') await api('POST', `/api/publishing/jobs/${id}/retry`);
|
||
else if (action === 'cancel') await api('POST', `/api/publishing/jobs/${id}/cancel`);
|
||
else if (action === 'delete') {
|
||
if (!confirm('Diesen abgeschlossenen Publishing-Auftrag aus dem Protokoll löschen? Das externe Angebot bleibt unberührt.')) return;
|
||
await api('DELETE', '/api/publishing/jobs', { ids: [id] });
|
||
} else if (['sync', 'update', 'end'].includes(action)) {
|
||
if (action === 'end' && !confirm('Das externe Angebot wirklich beenden?')) return;
|
||
await api('POST', `/api/publishing/jobs/${id}/action`, { action });
|
||
}
|
||
toast(action === 'sync' ? 'Statusabgleich eingereiht' : action === 'update' ? 'Aktualisierung eingereiht' : action === 'end' ? 'Beenden eingereiht' : 'Auftrag aktualisiert');
|
||
await loadPublishingQueueData();
|
||
} catch (error) { toast(`Publishing-Aktion fehlgeschlagen: ${error.message}`); }
|
||
}
|
||
|
||
async function deleteSelectedPublishingJobs() {
|
||
const ids = [...publishState.jobSelected];
|
||
if (!ids.length || !confirm(`${ids.length} abgeschlossene Publishing-Aufträge aus dem lokalen Protokoll löschen?`)) return;
|
||
try {
|
||
const result = await api('DELETE', '/api/publishing/jobs', { ids });
|
||
toast(`${result.deleted} Aufträge gelöscht`);
|
||
publishState.jobSelected.clear();
|
||
await loadPublishingQueueData();
|
||
} catch (error) { toast(`Löschen fehlgeschlagen: ${error.message}`); }
|
||
}
|
||
|
||
function renderPublishingErrorsWorkspace() {
|
||
const workspace = document.getElementById('publish-workspace');
|
||
const events = publishState.publishEvents || [];
|
||
const errors = events.filter(event => event.level === 'error' || ['failed', 'blocked', 'retry_wait'].includes(event.event_type));
|
||
workspace.innerHTML = `<header class="publish-operation-header"><div><span class="publish-panel-kicker">Diagnose & Audit</span><h3>Fehlerzentrale und Veröffentlichungsprotokoll</h3><p>${errors.length} relevante Fehler · ${events.length} Ereignisse geladen</p></div><button class="vd-button" type="button" data-publishing-events-refresh>Aktualisieren</button></header>
|
||
<div class="publishing-error-summary"><article><strong>${errors.length}</strong><span>Fehlerereignisse</span></article><article><strong>${events.filter(item => item.event_type === 'completed').length}</strong><span>Erfolgreiche Abschlüsse</span></article><article><strong>${events.filter(item => item.event_type === 'retry_wait').length}</strong><span>Automatische Wiederholungen</span></article></div>
|
||
<div class="publishing-event-list">${events.length ? events.map(renderPublishingEvent).join('') : '<div class="publish-workspace-empty compact"><span>✓</span><h3>Keine Ereignisse</h3><p>Neue Veröffentlichungsaktivitäten erscheinen hier.</p></div>'}</div>`;
|
||
workspace.querySelector('[data-publishing-events-refresh]')?.addEventListener('click', () => loadPublishingQueueData());
|
||
workspace.querySelectorAll('[data-event-open-job]').forEach(button => button.addEventListener('click', () => {
|
||
publishState.jobSearch = button.dataset.eventOpenJob;
|
||
publishState.jobStatus = '';
|
||
publishState.jobPage = 1;
|
||
setPublishView('queue');
|
||
loadPublishingQueueData();
|
||
}));
|
||
}
|
||
|
||
function renderPublishingEvent(event) {
|
||
return `<article class="publishing-event-row level-${escapeHtml(event.level)}">
|
||
<span class="publishing-event-dot"></span>
|
||
<div><strong>${escapeHtml(event.message)}</strong><small>${escapeHtml(event.title || event.sku || '')} · ${escapeHtml(platformLabel(event.platform))} · ${escapeHtml(publishingDate(event.created_at))}</small></div>
|
||
<span class="publishing-event-type">${escapeHtml(event.event_type)}</span>
|
||
${event.job_id ? `<button class="vd-button compact" type="button" data-event-open-job="${escapeHtml(event.job_id)}">Auftrag</button>` : '<span></span>'}
|
||
</article>`;
|
||
}
|
||
|
||
async function batchEbayAdd() {
|
||
const ids = [...publishState.selected];
|
||
if (!ids.length && publishState.selectedId) ids.push(publishState.selectedId);
|
||
if (!ids.length) return;
|
||
let created = 0;
|
||
let duplicates = 0;
|
||
let failed = 0;
|
||
for (const id of ids) {
|
||
const listing = publishState.listings.find(item => item.id === id) || publishState.allFiltered.find(item => item.id === id);
|
||
if (!listing) continue;
|
||
try {
|
||
const result = await api('POST', '/api/publishing/jobs', { listing_id: id, platform: listing.platform, action: 'publish' });
|
||
created += result.jobs?.length || 0;
|
||
duplicates += result.duplicates || 0;
|
||
} catch (error) {
|
||
if (error.code === 'DUPLICATE_PUBLISH' || /bereits.*veröffentlicht/i.test(error.message)) duplicates++;
|
||
else failed++;
|
||
}
|
||
}
|
||
toast(`${created} Auftrag${created === 1 ? '' : 'e'} eingereiht${duplicates ? ` · ${duplicates} bereits vorhanden/live` : ''}${failed ? ` · ${failed} fehlgeschlagen` : ''}`);
|
||
publishState.view = 'queue';
|
||
await loadPublishList();
|
||
setPublishView('queue');
|
||
}
|
||
|
||
async function processEbayQueue() {
|
||
setPublishView('queue');
|
||
await loadPublishingQueueData();
|
||
}
|
||
async function retryEbayQueue() {
|
||
setPublishView('errors');
|
||
await loadPublishingQueueData();
|
||
}
|
||
async function openEbayQueue() {
|
||
setPublishView('queue');
|
||
await loadPublishingQueueData();
|
||
}
|
||
|
||
function publishQueueCounts() {
|
||
const summary = publishState.jobsData?.summary || {};
|
||
return {
|
||
pending: Number(summary.queued || 0) + Number(summary.awaiting_user || 0) + Number(summary.retry_wait || 0),
|
||
processing: Number(summary.running || 0),
|
||
published: Number(summary.published || 0),
|
||
failed: Number(summary.failed || 0) + Number(summary.blocked || 0),
|
||
};
|
||
}
|
||
|
||
function renderPublishStatusRail() {
|
||
const counts = publishQueueCounts();
|
||
const summary = publishState.jobsData?.summary || {};
|
||
const workflowPublished = publishState.workflow?.stages?.published?.count || 0;
|
||
const directEligible = publishState.listings.filter(listing => ['ebay-de', 'etsy'].includes(listing.platform)).length;
|
||
const setText = (id, value) => { const node = document.getElementById(id); if (node) node.textContent = value; };
|
||
setText('publish-status-pending', counts.pending);
|
||
setText('publish-status-processing', counts.processing);
|
||
setText('publish-status-published', Math.max(counts.published, workflowPublished));
|
||
setText('publish-status-failed', counts.failed);
|
||
setText('publish-status-scheduled', publishState.scheduled.length);
|
||
setText('publish-direct-count', directEligible);
|
||
setText('publish-queue-count', publishState.jobsData?.pagination?.total || 0);
|
||
setText('publish-error-count', counts.failed);
|
||
setText('publish-scheduled-count', publishState.scheduled.length);
|
||
setText('publish-retry-caption', counts.failed ? `${counts.failed} Fehler prüfen` : 'Keine Fehler');
|
||
setText('publish-schedule-caption', publishState.scheduled.length ? `${publishState.scheduled.length} Planung${publishState.scheduled.length === 1 ? '' : 'en'}` : 'Keine Planungen');
|
||
const container = document.getElementById('publish-activity');
|
||
const events = publishState.publishEvents || [];
|
||
if (container) container.innerHTML = events.length ? events.slice(0, 6).map(event => `<div class="publish-activity-row"><span class="publish-activity-icon type-${escapeHtml(event.level || 'info')}">${event.level === 'error' ? '!' : event.event_type === 'completed' ? '✓' : '•'}</span><div><strong>${escapeHtml(event.message || '')}</strong><small>${relativeTime(event.created_at)}</small></div></div>`).join('') : '<div class="publish-rail-empty">Noch keine Publishing-Aktivität</div>';
|
||
}
|
||
|
||
|
||
// 1.39.0: Inline-Eventhandler entfernt; zentrale CSP-kompatible Delegation.
|
||
document.addEventListener('click', event => {
|
||
const close = event.target.closest?.('[data-close-target]');
|
||
if (close) document.getElementById(close.dataset.closeTarget)?.classList.add('hidden');
|
||
const kill = event.target.closest?.('[data-kill-session-id]');
|
||
if (kill) killSession(Number(kill.dataset.killSessionId));
|
||
const own = event.target.closest?.('[data-end-my-session-id]');
|
||
if (own) endMySession(Number(own.dataset.endMySessionId));
|
||
});
|