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

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

1495 lines
65 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function() {
'use strict';
const browserApi = globalThis.browser || null;
const chromeApi = globalThis.chrome || null;
const VENDOO_BRAND_ICON = 'data:image/png;base64,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';
function sendRuntimeMessage(message) {
if (browserApi) return browserApi.runtime.sendMessage(message);
return new Promise((resolve, reject) => chromeApi.runtime.sendMessage(message, response => {
const error = chromeApi.runtime.lastError;
if (error) reject(new Error(error.message)); else resolve(response);
}));
}
function storageGet(keys) { return browserApi ? browserApi.storage.local.get(keys) : new Promise(resolve => chromeApi.storage.local.get(keys, resolve)); }
function storageSet(values) { return browserApi ? browserApi.storage.local.set(values) : new Promise(resolve => chromeApi.storage.local.set(values, resolve)); }
const STATE = {
serverUrl: 'http://localhost:8124',
connected: false,
queue: [],
published: [],
selected: new Set(),
batchDelay: 45,
batchMode: 'draft',
batchRunning: false,
batchAbort: false,
batchProgress: { current: 0, total: 0, currentId: null },
};
let panel, listEl;
let serverCategories = null;
async function loadServerCategories() {
try {
const data = await api('/api/categories/vinted');
serverCategories = data;
if (data.tree) window.VINTED_CATEGORIES = data.tree;
if (data.flat) window.VINTED_CATEGORIES_FLAT = data.flat;
} catch {}
}
async function serverMatchCategory(query) {
if (!query) return [];
try {
return await api(`/api/categories/vinted/match?q=${encodeURIComponent(query)}`);
} catch {
return typeof matchCategory === 'function' ? matchCategory(query) : [];
}
}
// --- API via Background Service Worker ---
async function api(path, method, body) {
const resp = await sendRuntimeMessage({ type: 'api', serverUrl: STATE.serverUrl, path, method: method || 'GET', body });
if (!resp) throw new Error('Keine Antwort vom Extension-Background');
if (!resp.ok) throw new Error(resp.data?.error || resp.error || `HTTP ${resp.status}`);
return resp.data;
}
async function fetchImage(url) {
const resp = await sendRuntimeMessage({ type: 'fetch-image', url });
if (!resp || !resp.ok) throw new Error(resp?.error || 'Bild laden fehlgeschlagen');
return resp;
}
const IMAGE_CACHE = new Map();
function buildUploadUrl(filename) {
if (!filename) return '';
const safePath = String(filename).split('/').map(encodeURIComponent).join('/');
return `${STATE.serverUrl.replace(/\/$/, '')}/uploads/${safePath}`;
}
function extensionPhotoMarkup(filename, className, altText) {
if (!filename) return `<div class="${className} al-image-placeholder" aria-hidden="true"></div>`;
return `<img class="${className} al-image-loading" data-vendoo-photo="${attr(filename)}" alt="${attr(altText || '')}">`;
}
async function resolveExtensionPhoto(filename) {
if (!filename) return '';
if (IMAGE_CACHE.has(filename)) return IMAGE_CACHE.get(filename);
const promise = fetchImage(buildUploadUrl(filename))
.then(result => result.dataUrl)
.catch(error => {
IMAGE_CACHE.delete(filename);
throw error;
});
IMAGE_CACHE.set(filename, promise);
return promise;
}
function hydrateExtensionImages(root) {
if (!root) return;
root.querySelectorAll('img[data-vendoo-photo]').forEach(async image => {
const filename = image.dataset.vendooPhoto;
try {
image.src = await resolveExtensionPhoto(filename);
image.classList.remove('al-image-loading', 'al-image-error');
image.classList.add('al-image-ready');
} catch {
image.removeAttribute('src');
image.classList.remove('al-image-loading');
image.classList.add('al-image-error');
image.alt = 'Produktbild konnte nicht geladen werden';
}
});
}
// --- Helpers ---
function toast(msg, type) {
let t = document.querySelector('.al-toast');
if (!t) { t = document.createElement('div'); t.className = 'al-toast'; document.body.appendChild(t); }
t.textContent = msg;
t.className = 'al-toast ' + (type || '');
requestAnimationFrame(() => t.classList.add('show'));
setTimeout(() => t.classList.remove('show'), 3000);
}
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
async function loadSettings() {
try {
const d = await storageGet(['serverUrl', 'batchDelay', 'batchMode']);
if (d.serverUrl) STATE.serverUrl = d.serverUrl;
if (d.batchDelay) STATE.batchDelay = parseInt(d.batchDelay) || 45;
if (d.batchMode) STATE.batchMode = d.batchMode;
} catch {}
}
async function checkConnection() {
try {
await api('/api/providers');
STATE.connected = true;
loadServerCategories();
} catch {
STATE.connected = false;
}
updateStatusBadge();
}
function detectDiagnosticBrowser() {
const ua = navigator.userAgent || '';
if (/Edg\//.test(ua)) return 'edge';
if (/Firefox\//.test(ua)) return 'firefox';
if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) return 'safari';
return 'chrome';
}
async function sendDiagnosticHeartbeat() {
if (!STATE.connected) return;
try {
const stored = await storageGet(['vendooDiagnosticClientId']);
const clientId = stored.vendooDiagnosticClientId || (globalThis.crypto?.randomUUID?.() || `vendoo-${Date.now()}-${Math.random().toString(16).slice(2)}`);
if (!stored.vendooDiagnosticClientId) await storageSet({ vendooDiagnosticClientId: clientId });
await api('/api/extensions/heartbeat', 'POST', {
client_id: clientId,
browser: detectDiagnosticBrowser(),
version: '1.9.0',
platform: 'vinted-content',
server_url: STATE.serverUrl,
capabilities: { autofill: true, photos: true, publishing_queue: true, diagnostics: true },
});
} catch {}
}
function updateStatusBadge() {
const badge = panel?.querySelector('.al-hd-status');
if (!badge) return;
badge.textContent = STATE.connected ? 'Verbunden' : 'Offline';
badge.className = 'al-hd-status ' + (STATE.connected ? 'live' : 'off');
}
// --- Data ---
async function loadQueue() {
if (!STATE.connected) return;
try {
const data = await api('/api/extension/queue?platform=vinted');
STATE.queue = data.queue || [];
STATE.published = data.published || [];
const overviewCount = panel?.querySelector('#al-queue-overview-count');
const overviewTitle = panel?.querySelector('#al-queue-overview-title');
if (overviewCount) overviewCount.textContent = String(STATE.queue.length);
if (overviewTitle) overviewTitle.textContent = STATE.queue.length ? 'Bereit zum Ausfüllen' : 'Alles erledigt';
renderQueue();
updateFooter();
} catch (e) {
toast('Laden fehlgeschlagen: ' + e.message, 'error');
}
}
// --- UI ---
function createPanel() {
panel = document.createElement('div');
panel.id = 'al-panel';
panel.innerHTML = `
<div class="al-hd">
<img class="al-hd-brand-logo" src="${VENDOO_BRAND_ICON}" alt="" aria-hidden="true">
<span class="al-hd-logo">Vendoo Assistant</span>
<span class="al-hd-status off">...</span>
<span class="al-hd-sep"></span>
<button id="al-refresh" title="Aktualisieren">↻</button>
<button id="al-minimize" title="Minimieren">↙</button>
<button id="al-close" title="Schließen">×</button>
</div>
<div class="al-tabs">
<button class="al-tab active" data-tab="queue">Warteschlange</button>
<button class="al-tab" data-tab="done">Erledigt</button>
<button class="al-tab" data-tab="settings">Einstellungen</button>
</div>
<div class="al-batch-bar" id="al-batch-bar">
<input type="checkbox" class="al-cb" id="al-select-all" title="Alle auswählen">
<label for="al-select-all" style="font-size:11px;color:#888;cursor:pointer">Alle</label>
<span class="al-count" id="al-sel-count">0 ausgewählt</span>
</div>
<div class="al-body">
<div class="al-view active" id="al-view-queue">
<section class="al-queue-overview">
<div><span>Vinted Workflow</span><strong id="al-queue-overview-title">Bereit zum Ausfüllen</strong><small>Wähle einen Artikel und übertrage die Daten kontrolliert.</small></div>
<div class="al-queue-number"><b id="al-queue-overview-count">0</b><span>offen</span></div>
</section>
<ul class="al-list" id="al-queue-list"></ul>
</div>
<div class="al-view" id="al-view-done">
<ul class="al-list" id="al-done-list"></ul>
</div>
<div class="al-view" id="al-view-settings">
<div class="al-settings">
<div class="al-setting">
<div class="al-setting-label">Batch-Modus</div>
<select class="al-select" id="al-batch-mode">
<option value="draft" ${STATE.batchMode === 'draft' ? 'selected' : ''}>Als Entwurf speichern</option>
<option value="publish" ${STATE.batchMode === 'publish' ? 'selected' : ''}>Direkt veröffentlichen</option>
</select>
<small class="al-setting-hint">„Nur ausfüllen“ ist bewusst eine Einzelaktion und speichert oder veröffentlicht nichts.</small>
</div>
<div class="al-setting">
<div class="al-setting-label">Pause zwischen Artikeln (Sek.)</div>
<input type="number" class="al-input" id="al-delay" min="10" max="300" value="${STATE.batchDelay}">
</div>
<div class="al-setting">
<div class="al-setting-label">Server URL</div>
<input type="text" class="al-input" id="al-server-url" value="${STATE.serverUrl}">
</div>
<div class="al-setting">
<button class="al-btn al-btn-ghost al-btn-full" id="al-test-conn">Verbindung testen</button>
</div>
<div class="al-setting">
<button class="al-btn al-btn-ghost al-btn-full" id="al-open-app">Vendoo App öffnen</button>
</div>
</div>
</div>
<div class="al-view" id="al-view-detail"></div>
<div class="al-view" id="al-view-batch-progress"></div>
</div>
<div class="al-footer" id="al-footer">
<div class="al-footer-row" id="al-footer-actions">
<button class="al-btn al-btn-fill al-btn-full" id="al-start-batch" disabled>Artikel auswählen</button>
</div>
</div>
<div class="al-resize-handle" id="al-resize-handle"></div>
`;
document.body.appendChild(panel);
listEl = panel.querySelector('#al-queue-list');
bindEvents();
setupDrag();
setupResize();
}
function bindEvents() {
panel.querySelectorAll('.al-tab').forEach(tab =>
tab.addEventListener('click', () => switchTab(tab.dataset.tab))
);
panel.querySelector('#al-refresh').addEventListener('click', () => {
checkConnection().then(() => { if (STATE.connected) loadQueue(); });
});
panel.querySelector('#al-minimize').addEventListener('click', minimize);
panel.querySelector('#al-close').addEventListener('click', close);
panel.querySelector('#al-select-all').addEventListener('change', e => {
if (e.target.checked) STATE.queue.forEach(l => STATE.selected.add(l.id));
else STATE.selected.clear();
renderQueue(); updateFooter();
});
panel.querySelector('#al-start-batch').addEventListener('click', () => {
if (STATE.batchRunning) { STATE.batchAbort = true; return; }
startBatch();
});
panel.querySelector('#al-delay').addEventListener('change', e => {
STATE.batchDelay = Math.max(10, Math.min(300, parseInt(e.target.value) || 45));
storageSet({ batchDelay: String(STATE.batchDelay) });
});
panel.querySelector('#al-batch-mode').addEventListener('change', e => {
STATE.batchMode = e.target.value;
storageSet({ batchMode: STATE.batchMode });
updateFooter();
});
panel.querySelector('#al-test-conn').addEventListener('click', async () => {
STATE.serverUrl = panel.querySelector('#al-server-url').value.replace(/\/$/, '');
await storageSet({ serverUrl: STATE.serverUrl });
await checkConnection();
toast(STATE.connected ? 'Verbunden!' : 'Server nicht erreichbar', STATE.connected ? 'success' : 'error');
if (STATE.connected) loadQueue();
});
panel.querySelector('#al-open-app').addEventListener('click', () => {
window.open(STATE.serverUrl, '_blank');
});
}
function setupDrag() {
const hd = panel.querySelector('.al-hd');
let dragging = false, ox = 0, oy = 0;
hd.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
dragging = true;
const r = panel.getBoundingClientRect();
ox = e.clientX - r.left; oy = e.clientY - r.top;
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
panel.style.right = 'auto';
panel.style.top = (e.clientY - oy) + 'px';
panel.style.left = (e.clientX - ox) + 'px';
});
document.addEventListener('mouseup', () => { dragging = false; });
}
function setupResize() {
const handle = panel.querySelector('#al-resize-handle');
let resizing = false, startW, startH, startX, startY;
handle.addEventListener('mousedown', e => {
resizing = true;
startW = panel.offsetWidth;
startH = panel.offsetHeight;
startX = e.clientX;
startY = e.clientY;
e.preventDefault(); e.stopPropagation();
});
document.addEventListener('mousemove', e => {
if (!resizing) return;
panel.style.width = Math.max(300, startW + (e.clientX - startX)) + 'px';
panel.style.height = Math.max(350, startH + (e.clientY - startY)) + 'px';
});
document.addEventListener('mouseup', () => { resizing = false; });
}
function switchTab(tab) {
panel.querySelectorAll('.al-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
panel.querySelectorAll('.al-view').forEach(v => v.classList.remove('active'));
panel.querySelector(`#al-view-${tab}`).classList.add('active');
panel.querySelector('#al-batch-bar').style.display = tab === 'queue' ? '' : 'none';
panel.querySelector('#al-footer').style.display = (tab === 'queue' || tab === 'batch-progress') ? '' : 'none';
if (tab === 'done') renderDone();
}
function switchToView(name) {
panel.querySelectorAll('.al-view').forEach(v => v.classList.remove('active'));
panel.querySelector(`#al-view-${name}`).classList.add('active');
panel.querySelectorAll('.al-tab').forEach(t => t.classList.remove('active'));
panel.querySelector('#al-batch-bar').style.display = name === 'queue' ? '' : 'none';
panel.querySelector('#al-footer').style.display = name === 'batch-progress' ? '' : 'none';
}
function minimize() {
panel.remove();
const fab = document.createElement('button');
fab.className = 'al-fab'; fab.id = 'al-fab';
fab.innerHTML = `<img src="${VENDOO_BRAND_ICON}" alt="">`; fab.title = 'Vendoo Assistant öffnen';
document.body.appendChild(fab);
fab.addEventListener('click', () => { fab.remove(); createPanel(); init(); });
}
function close() { panel.remove(); panel = null; }
// --- Render ---
function renderQueue() {
if (!listEl) return;
if (!STATE.queue.length) {
listEl.innerHTML = '<li class="al-empty"><span class="al-empty-icon">✓</span><strong>Warteschlange erledigt</strong><small>Neue Vinted-Listings erscheinen automatisch hier.</small></li>';
return;
}
listEl.innerHTML = STATE.queue.map(l => {
const sel = STATE.selected.has(l.id);
const photo = l.photos?.[0] || '';
const cls = ['al-item'];
if (sel) cls.push('selected');
if (STATE.batchProgress.currentId === l.id) cls.push('processing');
const statusHtml = STATE.batchProgress.currentId === l.id ? '<span class="al-item-status running">Läuft</span>' : '<span class="al-item-status ready">Bereit</span>';
return `
<li class="${cls.join(' ')}" data-id="${l.id}">
<div class="al-item-select"><input type="checkbox" class="al-cb al-item-cb" data-id="${l.id}" ${sel ? 'checked' : ''} aria-label="${attr(l.title || 'Artikel')} auswählen"></div>
${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')}
<div class="al-item-info">
<div class="al-item-topline">${statusHtml}<span>Vinted</span></div>
<div class="al-item-title">${esc(l.title || 'Ohne Titel')}</div>
<div class="al-item-meta">${esc(l.brand || 'Ohne Marke')}${l.size ? ' · ' + esc(l.size) : ''}${l.condition ? ' · ' + esc(l.condition) : ''}</div>
<div class="al-item-sku">${esc(l.sku || `VD-${String(l.id).padStart(4, '0')}`)}</div>
</div>
<div class="al-item-side">${l.price ? `<strong>${Number(l.price).toFixed(2).replace('.', ',')} €</strong>` : '<strong>Preis fehlt</strong>'}<button class="al-item-open" type="button" data-id="${l.id}" title="Details öffnen"></button></div>
<div class="al-item-actions">
<button class="al-quick-fill" type="button" data-id="${l.id}"><span>↗</span> Daten</button>
<button class="al-quick-fill al-quick-fill-all" type="button" data-id="${l.id}">Bilder</button>
<button class="al-quick-detail" type="button" data-id="${l.id}">Prüfen</button>
</div>
</li>`;
}).join('');
hydrateExtensionImages(listEl);
listEl.querySelectorAll('.al-item-cb').forEach(cb =>
cb.addEventListener('change', e => {
e.stopPropagation();
const id = +e.target.dataset.id;
if (e.target.checked) STATE.selected.add(id); else STATE.selected.delete(id);
renderQueue(); updateFooter();
})
);
listEl.querySelectorAll('.al-quick-fill').forEach(button => button.addEventListener('click', async e => {
e.stopPropagation();
const listing = STATE.queue.find(l => l.id === +button.dataset.id);
if (!listing) return;
const includePhotos = button.classList.contains('al-quick-fill-all');
await fillListingData(listing, { includePhotos });
}));
listEl.querySelectorAll('.al-quick-detail,.al-item-open').forEach(button => button.addEventListener('click', e => {
e.stopPropagation();
showDetail(+button.dataset.id);
}));
listEl.querySelectorAll('.al-item').forEach(item =>
item.addEventListener('click', e => {
if (e.target.closest('button,input')) return;
showDetail(+item.dataset.id);
})
);
panel.querySelector('#al-select-all').checked = STATE.selected.size === STATE.queue.length && STATE.queue.length > 0;
}
function renderDone() {
const dl = panel.querySelector('#al-done-list');
if (!STATE.published.length) {
dl.innerHTML = '<li class="al-empty"><span class="al-empty-icon">&#128230;</span>Noch keine Artikel veröffentlicht.</li>';
return;
}
dl.innerHTML = STATE.published.map(l => {
const photo = l.photos?.[0] || '';
return `
<li class="al-item done">
${extensionPhotoMarkup(photo, 'al-item-img', l.title || 'Produktbild')}
<div class="al-item-info">
<div class="al-item-title">${esc(l.title || 'Ohne Titel')}</div>
<div class="al-item-meta">${esc(l.brand || '')}${l.price ? ' · ' + Number(l.price).toFixed(2) + ' €' : ''}</div>
</div>
<span class="al-item-status done">Erledigt</span>
</li>`;
}).join('');
hydrateExtensionImages(dl);
}
function updateFooter() {
const count = STATE.selected.size;
panel.querySelector('#al-sel-count').textContent = `${count} ausgewählt`;
const actions = panel.querySelector('#al-footer-actions');
if (STATE.batchRunning) {
actions.innerHTML = '<button class="al-btn al-btn-danger al-btn-full" id="al-start-batch">Batch abbrechen</button>';
actions.querySelector('#al-start-batch').addEventListener('click', () => { STATE.batchAbort = true; });
return;
}
if (count === 1) {
const id = [...STATE.selected][0];
actions.innerHTML = `
<button class="al-btn al-btn-fill" id="al-selection-fill">Nur Daten</button>
<button class="al-btn al-btn-ghost" id="al-selection-fill-all">Daten + Bilder</button>
<button class="al-btn al-btn-ghost" id="al-selection-detail">Prüfen</button>
<button class="al-btn al-btn-primary" id="al-batch-draft">Entwurf</button>
<button class="al-btn al-btn-fill" id="al-batch-publish">Live</button>`;
actions.querySelector('#al-selection-fill').addEventListener('click', async () => {
const listing = STATE.queue.find(l => l.id === id);
if (listing) await fillListingData(listing, { includePhotos: false });
});
actions.querySelector('#al-selection-fill-all').addEventListener('click', async () => {
const listing = STATE.queue.find(l => l.id === id);
if (listing) await fillListingData(listing, { includePhotos: true });
});
actions.querySelector('#al-selection-detail').addEventListener('click', () => showDetail(id));
actions.querySelector('#al-batch-draft').addEventListener('click', () => { STATE.batchMode = 'draft'; startBatch(); });
actions.querySelector('#al-batch-publish').addEventListener('click', () => { STATE.batchMode = 'publish'; startBatch(); });
} else if (count > 1) {
actions.innerHTML = `
<div class="al-footer-note"><strong>${count} Artikel ausgewählt</strong><span>Mehrfachauswahl unterstützt Batch-Entwurf oder direkte Veröffentlichung.</span></div>
<button class="al-btn al-btn-primary" id="al-batch-draft">Entwürfe</button>
<button class="al-btn al-btn-fill" id="al-batch-publish">Veröffentlichen</button>`;
actions.querySelector('#al-batch-draft').addEventListener('click', () => { STATE.batchMode = 'draft'; startBatch(); });
actions.querySelector('#al-batch-publish').addEventListener('click', () => { STATE.batchMode = 'publish'; startBatch(); });
} else {
actions.innerHTML = '<div class="al-footer-empty"><span>Aktionen sichtbar, sobald du ein Listing auswählst</span><small>Direkt in jeder Karte: Daten, Bilder und Prüfen.</small></div>';
}
}
// --- Detail ---
function showDetail(id) {
const listing = STATE.queue.find(l => l.id === id) || STATE.published.find(l => l.id === id);
if (!listing) return;
const view = panel.querySelector('#al-view-detail');
const photos = listing.photos || [];
view.innerHTML = `
<div class="al-detail">
<button class="al-detail-back">&larr; Zurück</button>
<div class="al-detail-hero">
${extensionPhotoMarkup(photos[0] || '', 'al-detail-main-photo', `${listing.title || 'Produkt'} · Cover`)}
<div class="al-detail-summary">
<span class="al-detail-ready">Bereit</span>
<h2>${esc(listing.title || 'Ohne Titel')}</h2>
<strong>${listing.price ? Number(listing.price).toFixed(2).replace('.', ',') + ' €' : 'Preis fehlt'}</strong>
<dl><dt>Kategorie</dt><dd>${esc(listing.category || 'Nicht gesetzt')}</dd><dt>Marke</dt><dd>${esc(listing.brand || '')}</dd><dt>Größe</dt><dd>${esc(listing.size || '')}</dd><dt>Zustand</dt><dd>${esc(listing.condition || '')}</dd></dl>
</div>
</div>
<div class="al-detail-photos">${photos.map((photo, index) => extensionPhotoMarkup(photo, 'al-detail-photo', `${listing.title || 'Produkt'} · Bild ${index + 1}`)).join('')}</div>
<div class="al-publish-flow">
<h3>Veröffentlichen auf Vinted</h3>
<div class="al-flow-grid">
<ol><li class="done"><b>1</b><span><strong>Daten prüfen</strong><small>Titel, Preis, Kategorie, Größe</small></span><em>✓</em></li><li class="done"><b>2</b><span><strong>Bilder vorbereiten</strong><small>${photos.length}/${photos.length || 0} Bilder verfügbar</small></span><em>✓</em></li><li class="done"><b>3</b><span><strong>Beschreibung übernehmen</strong><small>Beschreibung und Attribute</small></span><em>✓</em></li><li class="active"><b>4</b><span><strong>Auto-Fill auf Vinted</strong><small>Formular automatisch ausfüllen</small></span><em>○</em></li><li><b>5</b><span><strong>Veröffentlichung</strong><small>Listing abschließen</small></span><em>○</em></li></ol>
<div class="al-autofill-card"><span>Auto-Fill · Vinted</span><p>Vendoo übernimmt alle vorhandenen Listing-Daten.</p><ul><li>✓ Titel</li><li>✓ Preis</li><li>✓ Kategorie</li><li>✓ Größe &amp; Marke</li><li>✓ Beschreibung</li><li>✓ Bilder</li></ul><div><strong>✓ Bereit zum Übertragen</strong><small>Alle Pflichtdaten werden vorab geprüft.</small></div></div>
</div>
</div>
<details class="al-edit-details"><summary>Listing-Daten prüfen &amp; bearbeiten</summary>
<div class="al-field">
<div class="al-field-label">Titel</div>
<input type="text" id="al-d-title" value="${attr(listing.title || '')}">
</div>
<div class="al-field">
<div class="al-field-label">Beschreibung</div>
<textarea id="al-d-desc" rows="4">${esc(listing.description || '')}</textarea>
</div>
<div class="al-meta-row">
<div class="al-field">
<div class="al-field-label">Preis</div>
<input type="text" id="al-d-price" value="${attr(listing.price || '')}">
</div>
<div class="al-field">
<div class="al-field-label">Zustand</div>
<input type="text" id="al-d-condition" value="${attr(listing.condition || '')}">
</div>
</div>
<div class="al-meta-row">
<div class="al-field">
<div class="al-field-label">Marke</div>
<input type="text" id="al-d-brand" value="${attr(listing.brand || '')}">
</div>
<div class="al-field">
<div class="al-field-label">Größe</div>
<input type="text" id="al-d-size" value="${attr(listing.size || '')}">
</div>
</div>
<div class="al-field">
<div class="al-field-label">Kategorie (Vinted)</div>
<div class="al-cat-wrap" id="al-cat-wrap">
<input type="text" class="al-cat-input" id="al-d-category" placeholder="Kategorie suchen..." value="${attr(listing.category || '')}" autocomplete="off">
<div class="al-cat-dropdown" id="al-cat-dropdown"></div>
<div id="al-cat-selected-display"></div>
</div>
</div>
<div class="al-meta-row">
<div class="al-field">
<div class="al-field-label">Farbe</div>
<input type="text" id="al-d-color" value="${attr(listing.color || '')}">
</div>
<div class="al-field">
<div class="al-field-label">AI-Kategorie (Original)</div>
<input type="text" class="al-field-readonly" id="al-d-ai-category" value="${attr(listing.category || '')}" readonly style="opacity:0.6">
</div>
</div>
<div class="al-field">
<div class="al-field-label">Tags</div>
<input type="text" id="al-d-tags" value="${attr((listing.tags || []).join(' '))}">
</div>
</details>
<div class="al-detail-section-label">ÜBERTRAGEN OHNE SPEICHERN</div>
<div class="al-safe-actions">
<button class="al-btn al-btn-fill al-btn-full" id="al-d-fields"><span class="al-btn-icon">↗</span> Nur Daten ausfüllen</button>
<button class="al-btn al-btn-ghost al-btn-full" id="al-d-fill-all"><span class="al-btn-icon">▧</span> Daten + Bilder ausfüllen</button>
<p>Diese Aktionen füllen das Vinted-Formular nur aus. Es wird weder ein Entwurf gespeichert noch veröffentlicht.</p>
</div>
<details class="al-final-actions">
<summary>Entwurf speichern oder veröffentlichen</summary>
<div class="al-final-grid">
<button class="al-btn al-btn-primary" id="al-d-draft">Als Entwurf</button>
<button class="al-btn al-btn-fill" id="al-d-publish">Veröffentlichen</button>
<button class="al-btn al-btn-ghost" id="al-d-all-draft">Alles + Entwurf</button>
<button class="al-btn al-btn-ghost" id="al-d-all-publish">Alles + Veröffentlichen</button>
</div>
</details>
<button class="al-btn al-btn-ghost al-btn-full" id="al-d-copy">Listing-Daten kopieren</button>
</div>
`;
const v = view;
hydrateExtensionImages(view);
v.querySelector('.al-detail-back').addEventListener('click', () => switchTab('queue'));
// Category picker
initCategoryPicker(v, listing.category || '');
v.querySelector('#al-d-fields').addEventListener('click', async () => {
await fillListingData({ ...listing, ...readDetailFields() }, { includePhotos: false });
});
v.querySelector('#al-d-fill-all').addEventListener('click', async () => {
await fillListingData({ ...listing, ...readDetailFields(), photos: listing.photos || [] }, { includePhotos: true });
});
v.querySelector('#al-d-draft').addEventListener('click', async () => {
clickVintedButton('draft');
await markStatus(id, 'draft');
toast('Als Entwurf gespeichert', 'success');
});
v.querySelector('#al-d-publish').addEventListener('click', async () => {
clickVintedButton('publish');
await markStatus(id, 'published');
toast('Veröffentlicht!', 'success');
});
v.querySelector('#al-d-all-draft').addEventListener('click', async () => {
await doFullListing(listing, 'draft');
await markStatus(id, 'draft');
});
v.querySelector('#al-d-all-publish').addEventListener('click', async () => {
await doFullListing(listing, 'publish');
await markStatus(id, 'published');
});
v.querySelector('#al-d-copy').addEventListener('click', () => {
const f = readDetailFields();
navigator.clipboard.writeText(`${f.title}\n\n${f.description}\n\nPreis: ${f.price}\nKategorie: ${f.category}\nTags: ${f.tags}`);
toast('Kopiert!', 'success');
});
switchToView('detail');
}
function readDetailFields() {
return {
title: panel.querySelector('#al-d-title').value,
description: panel.querySelector('#al-d-desc').value,
price: panel.querySelector('#al-d-price').value,
condition: panel.querySelector('#al-d-condition').value,
brand: panel.querySelector('#al-d-brand').value,
size: panel.querySelector('#al-d-size').value,
category: panel.querySelector('#al-d-category').value,
color: panel.querySelector('#al-d-color').value,
tags: panel.querySelector('#al-d-tags').value,
};
}
// --- Sicheres Ausfüllen ohne Speichern oder Veröffentlichen ---
async function fillListingData(listing, options = {}) {
const includePhotos = options.includePhotos === true;
const fields = {
title: listing.title || '',
description: listing.description || '',
price: listing.price ? String(listing.price) : '',
condition: listing.condition || '',
brand: listing.brand || '',
size: listing.size || '',
category: listing.category || '',
color: listing.color || '',
tags: Array.isArray(listing.tags) ? listing.tags.join(' ') : (listing.tags || ''),
};
toast(includePhotos ? 'Daten und Bilder werden vorbereitet …' : 'Daten werden in Vinted eingetragen …');
if (includePhotos && Array.isArray(listing.photos) && listing.photos.length) {
await uploadPhotos(listing.photos);
await sleep(700);
}
fillVintedForm(fields);
await fillVintedChoiceFields(fields);
if (fields.category) await selectCategory(fields.category);
toast(includePhotos
? 'Daten und Bilder ausgefüllt noch nicht gespeichert.'
: 'Daten ausgefüllt noch nicht gespeichert.', 'success');
}
async function fillVintedChoiceFields(fields) {
const jobs = [
[['Marke', 'Brand'], fields.brand],
[['Größe', 'Size'], fields.size],
[['Zustand', 'Condition'], fields.condition],
[['Farbe', 'Color'], fields.color],
];
for (const [labels, value] of jobs) {
if (!value) continue;
await chooseVintedOption(labels, value);
}
}
async function chooseVintedOption(labels, value) {
const normalized = normalizedFieldText(value);
if (!normalized) return false;
const wantedLabels = labels.map(normalizedFieldText);
let trigger = null;
let triggerIsExplicitlyAssociated = false;
const labelNodes = [...document.querySelectorAll('label')]
.filter(label => !isExtensionElement(label))
.sort((a, b) => normalizedFieldText(a.textContent).length - normalizedFieldText(b.textContent).length);
for (const label of labelNodes) {
const text = normalizedFieldText(label.textContent);
if (!wantedLabels.some(item => text === item || text.startsWith(`${item} `) || text.startsWith(`${item}*`))) continue;
trigger = getAssociatedControl(label, 'button,[role="button"],select,input');
if (trigger) {
triggerIsExplicitlyAssociated = Boolean(
(label.htmlFor && trigger.id === label.htmlFor)
|| label.contains(trigger)
|| trigger.getAttribute?.('aria-labelledby')?.split(/\s+/).includes(label.id)
|| label.nextElementSibling?.contains?.(trigger)
|| label.nextElementSibling === trigger
);
break;
}
}
if (!trigger) {
for (const el of document.querySelectorAll('button,[role="button"]')) {
if (isExtensionElement(el)) continue;
const text = normalizedFieldText(el.textContent);
if (wantedLabels.some(item => text === item || text.startsWith(`${item} `))) { trigger = el; break; }
}
}
if (!trigger || isLikelyTitleField(trigger)) return false;
if (trigger.tagName === 'INPUT') {
const role = normalizedFieldText(trigger.getAttribute('role'));
const type = normalizedFieldText(trigger.type || 'text');
if (!triggerIsExplicitlyAssociated && role !== 'combobox') return false;
if (!['text', 'search', 'button'].includes(type) && role !== 'combobox') return false;
setNativeValue(trigger, value);
return true;
}
if (trigger.tagName === 'SELECT') {
const option = [...trigger.options].find(o => o.textContent.trim().toLowerCase().includes(normalized));
if (option) { trigger.value = option.value; trigger.dispatchEvent(new Event('change', { bubbles: true })); return true; }
return false;
}
trigger.click();
await sleep(350);
for (const option of document.querySelectorAll('[role="option"],li,button,[role="button"]')) {
if (option.closest('#al-panel')) continue;
const text = option.textContent.trim().toLowerCase();
if (text && (text === normalized || text.includes(normalized) || normalized.includes(text))) {
option.click();
await sleep(180);
return true;
}
}
return false;
}
// --- Full listing (photos + fields + category + submit) ---
async function doFullListing(listing, mode) {
const steps = panel.querySelector('#al-d-fields')?.parentElement;
if (!steps) return;
toast('Starte Komplett-Eintrag...', 'success');
// 13) Bilder, Felder und Kategorie ausfüllen
await fillListingData(listing, { includePhotos: true });
await sleep(700);
// 4) Submit
toast('Abschluss: ' + (mode === 'draft' ? 'Entwurf speichern …' : 'Veröffentlichen …'));
await sleep(500);
clickVintedButton(mode);
toast(mode === 'draft' ? 'Als Entwurf gespeichert!' : 'Veröffentlicht!', 'success');
}
// --- Photo Upload ---
async function uploadPhotos(photoFilenames) {
if (!photoFilenames.length) return { ok: false, count: 0 };
const dropzone = findPhotoDropzone();
const fileInput = findPhotoFileInput();
if (!dropzone && !fileInput) {
toast('Foto-Upload Bereich nicht gefunden. Bitte lade Fotos manuell hoch.', 'error');
return { ok: false, count: 0 };
}
const files = [];
for (const filename of photoFilenames) {
try {
const url = buildUploadUrl(filename);
const imgData = await fetchImage(url);
const byteString = atob(imgData.dataUrl.split(',')[1]);
const ab = new ArrayBuffer(byteString.length);
const ia = new Uint8Array(ab);
for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
const ext = filename.split('.').pop().toLowerCase();
const mime = imgData.mime || ({ jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', webp: 'image/webp', gif: 'image/gif' }[ext] || 'image/jpeg');
files.push(new File([ab], filename, { type: mime }));
} catch (err) {
console.warn('Vendoo: Foto laden fehlgeschlagen:', filename, err);
}
}
if (!files.length) {
toast('Keine Fotos geladen', 'error');
return { ok: false, count: 0 };
}
if (fileInput) {
try {
const dt = new DataTransfer();
files.forEach(f => dt.items.add(f));
fileInput.files = dt.files;
fileInput.dispatchEvent(new Event('change', { bubbles: true }));
toast(`${files.length} Fotos hochgeladen`, 'success');
return { ok: true, count: files.length };
} catch (err) {
console.warn('Vendoo: FileInput-Methode fehlgeschlagen, versuche Drop:', err);
}
}
if (dropzone) {
try {
const dt = new DataTransfer();
files.forEach(f => dt.items.add(f));
const dropEvent = new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt });
dropzone.dispatchEvent(new DragEvent('dragenter', { bubbles: true }));
dropzone.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt }));
dropzone.dispatchEvent(dropEvent);
toast(`${files.length} Fotos hochgeladen`, 'success');
return { ok: true, count: files.length };
} catch (err) {
console.warn('Vendoo: Drop-Methode fehlgeschlagen:', err);
}
}
toast('Automatischer Upload fehlgeschlagen. Bitte Fotos manuell hochladen.', 'error');
return { ok: false, count: 0 };
}
function findPhotoDropzone() {
const selectors = [
'[data-testid="photo-upload"]',
'[data-testid="file-upload"]',
'[class*="photo-upload"]',
'[class*="PhotoUpload"]',
'[class*="dropzone"]',
'[class*="Dropzone"]',
'[class*="media-upload"]',
'[class*="image-upload"]',
'.upload-dropzone',
'[class*="drag-and-drop"]',
];
for (const s of selectors) {
const el = document.querySelector(s);
if (el) return el;
}
const divs = document.querySelectorAll('div[class]');
for (const d of divs) {
if (d.className.match(/photo|upload|drop|media|image/i) && d.offsetHeight > 50) return d;
}
return null;
}
function findPhotoFileInput() {
const inputs = document.querySelectorAll('input[type="file"]');
for (const inp of inputs) {
if (inp.accept && inp.accept.includes('image')) return inp;
}
if (inputs.length) return inputs[0];
return null;
}
// --- Category Picker (in-panel dropdown with Vinted categories) ---
let selectedVintedCategory = null;
function initCategoryPicker(container, aiCategory) {
const input = container.querySelector('#al-d-category');
const dropdown = container.querySelector('#al-cat-dropdown');
const display = container.querySelector('#al-cat-selected-display');
if (!input || !dropdown || !display) return;
if (aiCategory) {
serverMatchCategory(aiCategory).then(matches => {
if (matches.length > 0) selectPickerCategory(matches[0], display);
renderDropdownResults(matches, dropdown, display);
});
}
input.addEventListener('focus', () => {
if (input.value.trim().length === 0) renderCategoryTree(dropdown, display);
else serverMatchCategory(input.value).then(m => renderDropdownResults(m, dropdown, display));
dropdown.classList.add('open');
});
let catSearchTimer;
input.addEventListener('input', () => {
clearTimeout(catSearchTimer);
catSearchTimer = setTimeout(() => {
const q = input.value.trim();
if (q.length === 0) renderCategoryTree(dropdown, display);
else serverMatchCategory(q).then(m => renderDropdownResults(m, dropdown, display));
dropdown.classList.add('open');
}, 200);
});
document.addEventListener('click', e => {
if (!container.querySelector('#al-cat-wrap')?.contains(e.target)) dropdown.classList.remove('open');
});
}
function renderDropdownResults(matches, dropdown, display) {
if (!matches.length) {
dropdown.innerHTML = '<div style="padding:8px;font-size:11px;color:#aaa;text-align:center">Keine Kategorie gefunden</div>';
return;
}
dropdown.innerHTML = matches.map((m, i) => `
<div class="al-cat-item" data-idx="${i}">
<div class="al-cat-item-name">${esc(m.name)}</div>
<div class="al-cat-item-path">${esc(m.path.slice(0, -1).join(' > '))}</div>
</div>
`).join('');
dropdown.querySelectorAll('.al-cat-item').forEach((el, i) => {
el.addEventListener('click', e => {
e.stopPropagation();
selectPickerCategory(matches[i], display);
dropdown.classList.remove('open');
});
});
}
function renderCategoryTree(dropdown, display) {
let html = '<div class="al-cat-tree">';
for (const main of VINTED_CATEGORIES) {
html += `<div class="al-cat-group">`;
html += `<div class="al-cat-group-hd" data-main="${main.id}"><span class="al-cat-arrow">&#9654;</span> ${esc(main.name)}</div>`;
html += `<div class="al-cat-group-children" data-main="${main.id}">`;
if (main.children) {
for (const sub of main.children) {
html += `<div class="al-cat-group">`;
html += `<div class="al-cat-group-hd" data-sub="${sub.id}" style="padding-left:16px"><span class="al-cat-arrow">&#9654;</span> ${esc(sub.name)}</div>`;
html += `<div class="al-cat-group-children" data-sub="${sub.id}">`;
if (sub.children) {
for (const leaf of sub.children) {
html += `<div class="al-cat-leaf" data-cat-id="${leaf.id}" data-path="${attr([main.name, sub.name, leaf.name].join(' > '))}" data-name="${attr(leaf.name)}">${esc(leaf.name)}</div>`;
}
}
html += '</div></div>';
}
}
html += '</div></div>';
}
html += '</div>';
dropdown.innerHTML = html;
dropdown.querySelectorAll('.al-cat-group-hd').forEach(hd => {
hd.addEventListener('click', e => {
e.stopPropagation();
const isMain = hd.dataset.main;
const isSub = hd.dataset.sub;
const sel = isMain ? `[data-main="${isMain}"]` : `[data-sub="${isSub}"]`;
const children = hd.nextElementSibling;
if (children) { children.classList.toggle('open'); hd.classList.toggle('expanded'); }
});
});
dropdown.querySelectorAll('.al-cat-leaf').forEach(leaf => {
leaf.addEventListener('click', e => {
e.stopPropagation();
selectPickerCategory({
id: +leaf.dataset.catId,
name: leaf.dataset.name,
path: leaf.dataset.path.split(' > '),
pathStr: leaf.dataset.path,
}, display);
dropdown.classList.remove('open');
});
});
}
function selectPickerCategory(cat, display) {
selectedVintedCategory = cat;
const input = panel.querySelector('#al-d-category');
if (input) input.value = cat.name;
if (display) {
display.innerHTML = `
<div class="al-cat-selected">
<div>
<div style="font-weight:600">${esc(cat.name)}</div>
<div class="al-cat-breadcrumb">${esc(cat.path ? cat.path.join(' > ') : cat.name)}</div>
</div>
<button class="al-cat-clear" title="Auswahl aufheben">&times;</button>
</div>
`;
display.querySelector('.al-cat-clear')?.addEventListener('click', () => {
selectedVintedCategory = null;
display.innerHTML = '';
if (input) input.value = '';
});
}
}
// --- Category Selection on Vinted page ---
async function selectCategory(category) {
if (!category && !selectedVintedCategory) {
toast('Keine Kategorie angegeben', 'error');
return;
}
let catData = selectedVintedCategory;
if (!catData && category) {
const matches = await serverMatchCategory(category);
catData = matches?.[0] || null;
}
if (!catData || !catData.path?.length) {
toast('Kategorie "' + category + '" nicht in Vinted-Kategorien gefunden. Bitte manuell wählen.', 'error');
return;
}
const catPath = catData.path;
const categoryBtn = findCategoryButton();
if (!categoryBtn) {
toast('Kategorie-Bereich auf Vinted nicht gefunden. Bitte manuell: ' + catPath.join(' > '), 'error');
return;
}
categoryBtn.click();
await sleep(1000);
for (let i = 0; i < catPath.length; i++) {
const found = await clickCategoryLevel(catPath[i]);
if (!found) {
toast(`Ebene "${catPath[i]}" nicht gefunden. Bisher: ${catPath.slice(0, i).join(' > ')}`, 'error');
return;
}
await sleep(600);
}
toast('Kategorie: ' + catPath.join(' > '), 'success');
}
function findCategoryButton() {
const selectors = [
'[data-testid*="catalog"]', '[data-testid*="category"]',
'[class*="catalog"]', '[class*="Category"]', '[class*="category"]',
];
for (const s of selectors) {
const el = document.querySelector(s + ' button') || document.querySelector(s);
if (el && !el.closest('#al-panel')) return el;
}
for (const el of document.querySelectorAll('button, [role="button"], a, div[class]')) {
if (el.closest('#al-panel')) continue;
const text = el.textContent.trim().toLowerCase();
if ((text.includes('kategorie') || text.includes('category') || text.includes('katalog'))
&& el.offsetHeight > 0 && el.offsetHeight < 200) return el;
}
return null;
}
async function clickCategoryLevel(name) {
const norm = name.toLowerCase().trim();
const containers = [
...document.querySelectorAll('[role="dialog"], [class*="modal"], [class*="overlay"], [class*="catalog"], [class*="category"]'),
document.body,
];
for (const container of containers) {
if (!container || container.closest('#al-panel')) continue;
const clickables = container.querySelectorAll('li, [role="option"], [role="button"], button, a, div[class*="item"], div[class*="option"], span[class*="name"]');
for (const el of clickables) {
if (el.closest('#al-panel')) continue;
const t = el.textContent.trim().toLowerCase();
if (t === norm || t.startsWith(norm) || norm.startsWith(t)) { el.click(); return true; }
}
for (const el of clickables) {
if (el.closest('#al-panel')) continue;
const t = el.textContent.trim().toLowerCase();
if ((t.includes(norm) || norm.includes(t)) && t.length > 1 && t.length < 100) { el.click(); return true; }
}
}
return false;
}
// --- Vinted Form Fill ---
function setNativeValue(el, value) {
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(el), 'value')?.set
|| Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
|| Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
if (setter) setter.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
function isExtensionElement(el) {
return !el || Boolean(el.closest?.('#al-panel'));
}
function isUsableFormControl(el, expectedTag = '') {
if (!el || isExtensionElement(el) || el.disabled) return false;
if (expectedTag && el.tagName?.toLowerCase() !== expectedTag.toLowerCase()) return false;
return true;
}
function normalizedFieldText(value) {
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
}
function getAssociatedControl(label, selector) {
if (!label) return null;
if (label.htmlFor) {
const byFor = document.getElementById(label.htmlFor);
if (byFor?.matches?.(selector) && isUsableFormControl(byFor)) return byFor;
}
const nested = label.querySelector(selector);
if (isUsableFormControl(nested)) return nested;
if (label.id && typeof CSS !== 'undefined' && CSS.escape) {
const escapedId = CSS.escape(label.id);
const labelledSelector = selector.split(',')
.map(part => `${part.trim()}[aria-labelledby~="${escapedId}"]`)
.join(', ');
const labelled = document.querySelector(labelledSelector);
if (isUsableFormControl(labelled)) return labelled;
}
const sibling = label.nextElementSibling;
if (sibling) {
const direct = sibling.matches?.(selector) ? sibling : sibling.querySelector?.(selector);
if (isUsableFormControl(direct)) return direct;
}
const parent = label.parentElement;
if (parent) {
const scopedSelector = selector.split(',').map(part => {
const item = part.trim();
return `:scope > ${item}, :scope > div > ${item}`;
}).join(', ');
const controls = [...parent.querySelectorAll(scopedSelector)]
.filter(control => isUsableFormControl(control));
if (controls.length === 1) return controls[0];
}
return null;
}
function findField(selectors) {
for (const selector of selectors) {
for (const el of document.querySelectorAll(selector)) {
if (isUsableFormControl(el)) return el;
}
}
return null;
}
function findByLabel(text, tag) {
const wanted = normalizedFieldText(text);
const labels = [...document.querySelectorAll('label')]
.filter(label => !isExtensionElement(label))
.sort((a, b) => normalizedFieldText(a.textContent).length - normalizedFieldText(b.textContent).length);
for (const label of labels) {
const labelText = normalizedFieldText(label.textContent);
if (!(labelText === wanted || labelText.startsWith(`${wanted} `) || labelText.startsWith(`${wanted}*`))) continue;
const control = getAssociatedControl(label, tag);
if (isUsableFormControl(control, tag)) return control;
}
return null;
}
function isLikelyTitleField(el) {
if (!el) return false;
const identity = normalizedFieldText([
el.name,
el.id,
el.getAttribute?.('data-testid'),
el.getAttribute?.('aria-label'),
el.getAttribute?.('placeholder'),
].filter(Boolean).join(' '));
return /(^|[\s_-])(title|titel)([\s_-]|$)/.test(identity);
}
function fillVintedForm(fields) {
let filled = 0;
const titleEl = findField([
'[data-testid="title-input"] input',
'[data-testid*="title" i] input',
'input[name="title"]',
'input[id*="title" i]',
'input[aria-label*="Titel" i]',
'input[aria-label*="Title" i]',
'input[placeholder*="Titel" i]',
'input[placeholder*="Title" i]'
]) || findByLabel('Titel', 'input') || findByLabel('Title', 'input');
if (titleEl && fields.title) { setNativeValue(titleEl, fields.title); filled++; }
const descEl = findField(['[data-testid="description-input"] textarea', 'textarea[name="description"]', '[class*="Description"] textarea'])
|| findByLabel('Beschreib', 'textarea') || findByLabel('Describe', 'textarea');
if (descEl && fields.description) { setNativeValue(descEl, fields.description); filled++; }
const priceEl = findField(['[data-testid="price-input"] input', 'input[name="price"]', '[class*="Price"] input'])
|| findByLabel('Preis', 'input') || findByLabel('Price', 'input');
if (priceEl && fields.price) {
setNativeValue(priceEl, String(fields.price).replace(/[^\d.,]/g, '').replace(',', '.'));
filled++;
}
if (fields.tags) {
const tagEl = findField(['[data-testid="hashtag-input"] input', 'input[name="hashtags"]'])
|| findByLabel('Hashtag', 'input');
if (tagEl) {
const tagList = fields.tags.split(/\s+/).filter(Boolean);
tagList.forEach((tag, i) => {
setTimeout(() => {
setNativeValue(tagEl, tag.replace(/^#/, ''));
tagEl.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', keyCode: 13, bubbles: true }));
}, i * 250);
});
filled++;
}
}
toast(filled > 0 ? `${filled} Felder ausgefüllt` : 'Keine Felder gefunden — richtige Seite?', filled > 0 ? 'success' : 'error');
return filled;
}
// --- Submit / Draft ---
function clickVintedButton(mode) {
const allBtns = document.querySelectorAll('button[type="submit"], button[class*="submit"], button[data-testid]');
if (mode === 'draft') {
for (const btn of document.querySelectorAll('button, a, [role="button"]')) {
const text = btn.textContent.trim().toLowerCase();
if (text.includes('entwurf') || text.includes('draft') || text.includes('save as draft') || text.includes('als entwurf')) {
btn.click();
return true;
}
}
toast('Entwurf-Button nicht gefunden. Bitte manuell als Entwurf speichern.', 'error');
return false;
}
if (mode === 'publish') {
for (const btn of document.querySelectorAll('button, [role="button"]')) {
const text = btn.textContent.trim().toLowerCase();
if (text.includes('hochladen') || text.includes('upload') || text.includes('veröffentlichen')
|| text.includes('publish') || text.includes('anzeige aufgeben') || text.includes('list now')) {
btn.click();
return true;
}
}
for (const btn of allBtns) {
if (btn.type === 'submit' && !btn.closest('#al-panel')) {
btn.click();
return true;
}
}
toast('Veröffentlichen-Button nicht gefunden. Bitte manuell veröffentlichen.', 'error');
return false;
}
return false;
}
// --- Status tracking ---
async function markStatus(id, status) {
try {
await api('/api/publish/mark', 'POST', { listing_id: id, platform: 'vinted', status });
} catch {}
}
// --- Batch ---
async function startBatch() {
const ids = [...STATE.selected];
if (!ids.length) return;
STATE.batchRunning = true;
STATE.batchAbort = false;
STATE.batchProgress = { current: 0, total: ids.length, currentId: null };
updateFooter();
showBatchProgress();
for (let i = 0; i < ids.length; i++) {
if (STATE.batchAbort) break;
const id = ids[i];
STATE.batchProgress.current = i + 1;
STATE.batchProgress.currentId = id;
renderQueue();
updateBatchProgress();
try {
const data = await api(`/api/extension/listing/${id}/photos`);
const l = data.listing;
const photoFiles = l.photos || [];
// 1) Upload photos
logBatchStep(id, 'Fotos hochladen...');
await uploadPhotos(typeof photoFiles === 'string' ? photoFiles.split(' ').filter(Boolean) : photoFiles);
await sleep(1500);
// 2) Fill fields
logBatchStep(id, 'Felder ausfüllen...');
fillVintedForm({
title: l.title, description: l.description,
price: l.price ? String(l.price) : '',
condition: l.condition || '', brand: l.brand || '',
size: l.size || '', tags: (l.tags || []).join(' '),
});
await sleep(1000);
// 3) Category
if (l.category) {
logBatchStep(id, 'Kategorie wählen...');
await selectCategory(l.category);
await sleep(1000);
}
// 4) Submit
logBatchStep(id, STATE.batchMode === 'draft' ? 'Als Entwurf speichern...' : 'Veröffentlichen...');
await sleep(500);
clickVintedButton(STATE.batchMode);
await markStatus(id, STATE.batchMode === 'draft' ? 'draft' : 'published');
logBatchItem(id, 'done');
// Wait for page to process
await sleep(3000);
} catch (err) {
logBatchItem(id, 'error', err.message);
}
if (i < ids.length - 1 && !STATE.batchAbort) {
await countdown(STATE.batchDelay);
}
}
STATE.batchRunning = false;
STATE.batchProgress.currentId = null;
updateFooter();
toast(STATE.batchAbort ? 'Batch abgebrochen' : `${ids.length} Artikel eingetragen!`, STATE.batchAbort ? 'error' : 'success');
STATE.selected.clear();
await loadQueue();
switchTab('queue');
}
function showBatchProgress() {
const view = panel.querySelector('#al-view-batch-progress');
const modeText = STATE.batchMode === 'draft' ? 'Entwurf' : 'Veröffentlichen';
view.innerHTML = `
<div style="padding:12px">
<div style="font-size:11px;color:#888;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:6px">
Modus: <strong style="color:${STATE.batchMode === 'draft' ? '#2c6e49' : '#c77b2a'}">${modeText}</strong>
</div>
<div class="al-progress">
<div class="al-progress-bar"><div class="al-progress-fill" id="al-bp-fill" style="width:0%"></div></div>
<div class="al-progress-text">
<span id="al-bp-text">0 / ${STATE.batchProgress.total}</span>
<span id="al-bp-percent">0%</span>
</div>
</div>
<div class="al-countdown" id="al-countdown" style="display:none">
<div class="al-countdown-label">Nächster Artikel in</div>
<span id="al-cd-time">--</span>s
</div>
<div id="al-bp-log" style="margin-top:8px;font-size:11px;color:#888;max-height:250px;overflow-y:auto"></div>
</div>
`;
switchToView('batch-progress');
panel.querySelector('#al-footer').style.display = '';
}
function updateBatchProgress() {
const { current, total } = STATE.batchProgress;
const pct = Math.round((current / total) * 100);
const fill = panel.querySelector('#al-bp-fill');
const text = panel.querySelector('#al-bp-text');
const percent = panel.querySelector('#al-bp-percent');
if (fill) fill.style.width = pct + '%';
if (text) text.textContent = `${current} / ${total}`;
if (percent) percent.textContent = pct + '%';
}
function logBatchStep(id, step) {
const log = panel.querySelector('#al-bp-log');
if (!log) return;
const listing = STATE.queue.find(l => l.id === id);
const title = listing ? listing.title : `#${id}`;
log.innerHTML += `<div style="margin-bottom:2px;color:#aaa">&#8226; ${esc(title.substring(0, 30))}... ${step}</div>`;
log.scrollTop = log.scrollHeight;
}
function logBatchItem(id, status, msg) {
const log = panel.querySelector('#al-bp-log');
if (!log) return;
const listing = STATE.queue.find(l => l.id === id);
const title = listing ? listing.title : `#${id}`;
const icon = status === 'done' ? '&#10003;' : '&#10007;';
const color = status === 'done' ? '#2c6e49' : '#c0392b';
log.innerHTML += `<div style="margin-bottom:3px;font-weight:600"><span style="color:${color}">${icon}</span> ${esc(title)}${msg ? ' — ' + esc(msg) : ''}</div>`;
log.scrollTop = log.scrollHeight;
}
function countdown(seconds) {
return new Promise(resolve => {
const cdEl = panel.querySelector('#al-countdown');
const timeEl = panel.querySelector('#al-cd-time');
if (!cdEl) { setTimeout(resolve, seconds * 1000); return; }
cdEl.style.display = '';
let remaining = seconds;
timeEl.textContent = remaining;
const iv = setInterval(() => {
remaining--;
timeEl.textContent = remaining;
if (remaining <= 0 || STATE.batchAbort) {
clearInterval(iv);
cdEl.style.display = 'none';
resolve();
}
}, 1000);
});
}
// --- Util ---
function esc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function attr(s) { return String(s).replace(/"/g, '&quot;'); }
// --- Init ---
async function init() {
await loadSettings();
if (!panel) createPanel();
await checkConnection();
if (STATE.connected) {
await loadQueue();
await sendDiagnosticHeartbeat();
const pending = await storageGet(['pendingFillListingId', 'pendingFillIncludePhotos', 'pendingOpenListingId']);
if (pending.pendingFillListingId) {
const listing = STATE.queue.find(item => item.id === Number(pending.pendingFillListingId));
if (listing) {
const includePhotos = pending.pendingFillIncludePhotos === true;
await storageSet({ pendingFillListingId: null, pendingFillIncludePhotos: null });
await fillListingData(listing, { includePhotos });
showDetail(listing.id);
}
}
if (pending.pendingOpenListingId) {
const detailListing = STATE.queue.find(item => item.id === Number(pending.pendingOpenListingId));
await storageSet({ pendingOpenListingId: null });
if (detailListing) showDetail(detailListing.id);
}
}
}
const runtime = browserApi?.runtime || chromeApi?.runtime;
runtime?.onMessage?.addListener((message, _sender, sendResponse) => {
if (message?.type !== 'vendoo-fill-listing') return;
(async () => {
const listing = STATE.queue.find(item => item.id === Number(message.listingId));
if (!listing) throw new Error('Listing nicht in der Warteschlange gefunden');
await fillListingData(listing, { includePhotos: message.includePhotos === true });
showDetail(listing.id);
return { ok: true };
})().then(sendResponse).catch(error => sendResponse({ ok: false, error: error.message }));
return true;
});
setInterval(async () => {
if (!STATE.connected) await checkConnection();
if (STATE.connected) await sendDiagnosticHeartbeat();
}, 60 * 1000);
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();