* docs: prepare Vendoo 1.41.2 git-ignore boundary hotfix * fix: track native source modules with root-anchored runtime ignores
1495 lines
65 KiB
JavaScript
1495 lines
65 KiB
JavaScript
(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">📦</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">← 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 & 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 & 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');
|
||
|
||
// 1–3) 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">▶</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">▶</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">×</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">• ${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' ? '✓' : '✗';
|
||
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, '"'); }
|
||
|
||
// --- 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();
|
||
})();
|
||
|